From 4b78b6577cf1b4eb7c6bad02f3d0733e9d7d0ec5 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 15:38:20 +0800 Subject: [PATCH 01/25] feat(client): inject reset-aware disclosure hooks --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- packages/client/ui-chat/src/client/apply.ts | 10 +- .../src/client/chat/AssistantMarkdown.tsx | 9 +- .../src/client/chat/AssistantNodeView.tsx | 3 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 16 ++- .../ui-chat/src/client/chat/ReasoningRow.tsx | 11 ++- .../ui-chat/src/client/chat/use-disclosure.ts | 33 +++++++ .../ui-chat/src/client/contract/slots.ts | 31 +++++- packages/client/ui-chat/src/client/index.ts | 4 +- ...stant-markdown-path-images.client.spec.tsx | 5 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 39 +++++++- .../tests/chat-branch-tails.client.spec.tsx | 3 +- .../ui-chat/tests/chat-view.client.spec.tsx | 11 ++- .../tests/coverage-tails.client.spec.tsx | 9 +- .../tests/gate-branch-tails.client.spec.tsx | 5 +- .../tests/image-labels.client.spec.tsx | 7 +- .../tests/reasoning-row.client.spec.tsx | 43 ++++---- .../tests/use-disclosure.client.spec.tsx | 98 +++++++++++++++++++ packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 + packages/client/ui-tool/README.zh.md | 2 + .../ui-tool/src/client/contract/slots.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 16 +-- .../src/client/tool/components/ToolRow.tsx | 12 +-- .../client/tool/toolviews/GenericToolCard.tsx | 3 +- .../tool/toolviews/ask-question-row.tsx | 3 +- .../src/client/tool/toolviews/bash-sample.tsx | 9 +- .../src/client/tool/toolviews/details-row.tsx | 3 +- .../tool/toolviews/file-mutation-row.tsx | 3 +- .../client/tool/toolviews/read-family-row.tsx | 3 +- .../src/client/tool/toolviews/search-row.tsx | 3 +- .../src/client/tool/toolviews/todo-row.tsx | 3 +- .../src/client/tool/toolviews/web-row.tsx | 3 +- .../tests/ask-question-row.client.spec.tsx | 3 +- .../tests/chat-ptc-subcalls.client.spec.tsx | 1 + .../tests/coverage-tails.client.spec.tsx | 7 +- .../ui-tool/tests/details-row.client.spec.tsx | 3 +- .../ui-tool/tests/diff-card.client.spec.tsx | 7 +- .../ui-tool/tests/image-card.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 7 +- .../ui-tool/tests/search-card.client.spec.tsx | 5 +- .../tests/terminal-card.client.spec.tsx | 5 +- .../ui-tool/tests/todo-row.client.spec.tsx | 3 +- .../tests/tool-call-tree.client.spec.tsx | 37 ++++++- .../ui-tool/tests/tool-row.client.spec.tsx | 4 +- .../ui-tool/tests/web-card.client.spec.tsx | 3 +- .../tests/workflow-run.client.spec.tsx | 1 + .../src/client/slot-catalog.ts | 17 ++-- 50 files changed, 403 insertions(+), 125 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/use-disclosure.ts create mode 100644 packages/client/ui-chat/tests/use-disclosure.client.spec.tsx diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ee8dc73bfc..27b73506a4 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: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c -README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6 +README.md: 0b4242b27952fd9570f82284f06e131069aa8e70 +README.zh.md: 1b047a6904acaca696744b1a42594ccceef8a082 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 0fd407f33b..0b4242b279 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -66,7 +66,7 @@ The completed-turn action footer starts 20px below the preceding prose or extens ## Turn Process Folding -Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line. +Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice unless the enclosing Turn hides the row. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line. Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded. @@ -93,6 +93,8 @@ Chat can render optional Conversation Group Definition output as a mixed `node`/ The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition. +The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index e9d5005851..1b047a6904 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -66,7 +66,7 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ## 轮次过程折叠 -每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。 +每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择,除非外层轮次隐藏了该行。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。 思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。 @@ -93,6 +93,8 @@ Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `nod 组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。 +Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 + ----- diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 6a27e68be9..69885d3ed4 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -21,7 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { - ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, + ChatNodeInjected, ChatScrollPosition, ChatViewInjected, TurnTailOwnerProps, } from './contract/slots.ts' import type { ChatSnapshot } from './contract/snapshot.ts' @@ -41,12 +41,14 @@ import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpen import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' import { PerformanceUsagePolicy } from './performance-usage.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' +import { bindDisclosure } from './chat/use-disclosure.ts' -const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { +const CHAT_NODE_INJECT: ChatNodeInjected = { hooks: { - turnData: (_standard, data) => function useTurnData(key) { - return useTurnDataValue(data, key) + turnData: (_standard, { turnData }) => function useTurnData(key) { + return useTurnDataValue(turnData, key) }, + disclosure: (_standard, { disclosureReset }) => bindDisclosure(disclosureReset), }, } diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index da45e543a9..8170413bad 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' @@ -23,6 +23,8 @@ export function localPathMediaUrl(base: string, value: string): string | undefin } export interface AssistantMarkdownProps { + /** Stable Hook forwarded to each independently expandable reasoning block. */ + useDisclosure: UseDisclosure blocks: readonly AssistantBlock[] streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ @@ -43,7 +45,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, + blocks, streaming, interrupted, renderMessageImages, useDisclosure, reasoningHidden = false, usePresentation, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object @@ -85,7 +87,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ hidden={reasoningHidden} reveal={revealProcess} > - + , ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 9ef72941f5..6073a5885d 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -7,7 +7,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace

createSnapshotStore(0)) + const turnData = turnDataOf(routedNode) + const hookContext = useMemo(() => ({ turnData, disclosureReset }), [turnData, disclosureReset]) + useEffect(() => { + if (processMember && processHidden && wrapperRef.current?.hasAttribute('hidden')) { + disclosureReset.set(disclosureReset.getSnapshot() + 1) + } + }, [processMember, processHidden, wrapperRef, disclosureReset]) const owner = useMemo(() => node === undefined ? null : { @@ -126,7 +135,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null - const turnData = turnDataOf(routedNode) // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not // distribute an object containing a union into a union of objects itself. @@ -148,7 +156,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ > {renderSlot('conversation.chat.node', routedOwner, { entryKey: routedNode.kind, - hookContext: turnData, + hookContext, fallback: ( markdownLabels(t), [t]) - const toggle = useCallback(() => { setExpanded(value => !value) }, []) const summaryText = running ? latestCompletedParagraphFirstLine(text) : firstLine(text) const summary = useMemo(() => summaryText.replaceAll('**', ''), [summaryText]) const preview = usePresentation(policy => !expanded && summary !== '' diff --git a/packages/client/ui-chat/src/client/chat/use-disclosure.ts b/packages/client/ui-chat/src/client/chat/use-disclosure.ts new file mode 100644 index 0000000000..663254e4b5 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/use-disclosure.ts @@ -0,0 +1,33 @@ +/** Bind independent disclosure state to a Chat seat's reset source. */ +import { useCallback, useState, useSyncExternalStore } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { UseDisclosure } from '../contract/slots.ts' + +/** + * Own one initially collapsed disclosure without an external subscription. + * @param version - reset generation; unchanged generations retain local open state. + * @returns the open state, an explicit setter, and a toggle action. + */ +export function useDisclosure(version = 0): ReturnType { + const [expandedVersion, setExpandedVersion] = useState(null) + const expanded = expandedVersion === version + const setExpanded = useCallback((open: boolean) => { + setExpandedVersion(open ? version : null) + }, [version]) + const toggle = useCallback(() => { + setExpandedVersion(previous => previous === version ? null : version) + }, [version]) + return { expanded, setExpanded, toggle } +} + +/** + * Bind a Hook without subscribing until a component calls it. + * @param reset - stable source whose version advances when the seat is hidden by its Turn. + * @returns a Hook with independent open state for each invocation. + */ +export function bindDisclosure(reset: ObservableSnapshot): UseDisclosure { + return function useBoundDisclosure() { + const version = useSyncExternalStore(reset.subscribe, reset.getSnapshot) + return useDisclosure(version) + } +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 312dcc2a76..e28ce1299c 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -80,9 +80,30 @@ export type UseChatNodeTurnData = Readonly | undefined -/** Slot-level Hook factory for keyed Chat renderers. */ -export interface ChatNodeTurnDataInjected { - hooks: { turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> } +/** + * Subscribe to enclosing-Turn resets and own one initially collapsed disclosure. + * Each invocation has independent open state; display-mode changes do not reset it. + * @returns the current open state, explicit setter, and toggle action. + */ +export type UseDisclosure = () => { + readonly expanded: boolean + /** @param open - whether this disclosure is expanded. */ + readonly setExpanded: (open: boolean) => void + readonly toggle: () => void +} + +/** Stable sources bound to one rendered Chat Node. */ +export interface ChatNodeHookContext { + readonly turnData: ConversationLocationDataStore | undefined + readonly disclosureReset: ObservableSnapshot +} + +/** Slot-level Hook factories for keyed Chat renderers. */ +export interface ChatNodeInjected { + hooks: { + turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData> + disclosure: SlotHookFactory<'conversation.chat.node', UseDisclosure> + } } /** Stable owner currency delivered to a keyed Chat renderer. */ @@ -219,8 +240,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { scope: 'session' owner: ChatNodeOwnerProps keyProps: { [Kind in ChatNodeKind]: { node: ChatNode } } - hookContext: ConversationLocationDataStore | undefined - inject: ChatNodeTurnDataInjected + hookContext: ChatNodeHookContext + inject: ChatNodeInjected } /** * Renderer for one consecutive group of durable message images. The owner diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 20e33d8885..017e379ab2 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -32,10 +32,10 @@ export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settin export type { TranscriptViewMode } from '../chat-settings.ts' export type { ChatPresentationPolicy } from './presentation-policy.ts' export type { - AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, + AssistantActionOwnerProps, ChatFileMentions, ChatNodeHookContext, ChatNodeInjected, ChatNodeOwnerProps, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, MessageImagesProps, OpenFileOptions, PresentationInjected, - TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UsePresentation, + TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, UseDisclosure, UsePresentation, } from './contract/slots.ts' export type { TurnProcessSpec, diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 77bc597140..5040efcdc0 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -3,6 +3,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' import type { AssistantBlock } from '../src/client/contract/snapshot.ts' @@ -54,7 +55,7 @@ describe('localPathMediaUrl', () => { describe('AssistantMarkdown local-path images', () => { it('renders a local image path in closing prose through the same-origin API', () => { const { container } = render( - { it('keeps non-absolute destinations inert', () => { const { container } = render( - { it('binds Turn data directly to its keyed Location source', async () => { const b = await bench() const spec = b.runtime.slots.spec('conversation.chat.node') as unknown as { - inject: ChatNodeTurnDataInjected + inject: ChatNodeInjected } let value: number | undefined = 42 const listeners = new Set<() => void>() @@ -213,7 +213,7 @@ describe('Chat apply wiring', () => { const useChat = vi.fn(() => { throw new Error('Turn data must not read the Chat snapshot') }) const useTurnData = spec.inject.hooks.turnData( { useChat } as unknown as Parameters[0], - data, + { turnData: data, disclosureReset: createSnapshotStore(0) }, ) const Probe = ({ useData }: { useData: UseChatNodeTurnData }) => ( {useData('metric') ?? 'missing'} @@ -231,7 +231,7 @@ describe('Chat apply wiring', () => { view.rerender([0], - undefined, + { turnData: undefined, disclosureReset: createSnapshotStore(0) }, )} />) expect(view.getByText('missing')).toBeTruthy() expect(useChat).not.toHaveBeenCalled() @@ -239,4 +239,33 @@ describe('Chat apply wiring', () => { view.unmount() await b.runtime.dispose() }) + + it('injects local disclosures bound to their Chat seat reset source', async () => { + const b = await bench() + try { + const spec = b.runtime.slots.spec('conversation.chat.node') as { inject: ChatNodeInjected } + const reset = createSnapshotStore(0) + const useDisclosure = spec.inject.hooks.disclosure( + {} as Parameters[0], + { turnData: undefined, disclosureReset: reset }, + ) + function Probe({ useDisclosure }: { useDisclosure: UseDisclosure }) { + const { expanded, toggle } = useDisclosure() + return + } + const view = render() + try { + const button = view.getByRole('button', { name: 'Details' }) + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + act(() => { reset.set(1) }) + expect(button.getAttribute('aria-expanded')).toBe('false') + expect(view.getByRole('button', { name: 'Details' })).toBe(button) + } finally { + view.unmount() + } + } finally { + await b.runtime.dispose() + } + }) }) diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index f389653c72..5880bc5792 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -17,6 +17,7 @@ import { } from '../src/client/chat/MessageItem.tsx' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -1044,7 +1045,7 @@ describe('useCalendarDay boundary refresh', () => { describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( - | undefined + const { turnData, disclosureReset } = opts?.hookContext as ChatNodeHookContext const useTurnData: UseChatNodeTurnData = dataKey => useTurnDataValue(turnData, dataKey) + const useDisclosure = bindDisclosure(disclosureReset) const nodeProps = (): ChatNodeViewProps => ( - { ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps + { ...props, ...nodeOwner, useTurnData, useDisclosure } as unknown as ChatNodeViewProps ) switch (nodeOwner.node.kind) { case 'user': diff --git a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx index bc759ada20..e3fe9f218e 100644 --- a/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/coverage-tails.client.spec.tsx @@ -6,6 +6,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { zh } from '../src/client/locale.ts' const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) @@ -16,7 +17,7 @@ afterEach(cleanup) describe('tails', () => { it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => { const view = render( - { expect(view.getByText('thinking hard')).toBeTruthy() expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( - { // Tool heads are drawn by ChatView's tool groups; an empty root between // groups is layout noise (no text, no pulse, no interrupted marker). const empty = render( - { ) expect(empty.container.firstChild).toBeNull() const blank = render( - , ) diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d97056af81..f027c87b0b 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -6,6 +6,7 @@ import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-tes import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { useDetailedPresentation } from './presentation-fixture.client.ts' +import { useDisclosure } from '../src/client/chat/use-disclosure.ts' import { StatsPills } from '../src/client/chat/StatsPills.tsx' import { zh } from '../src/client/locale.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -22,7 +23,7 @@ afterEach(() => { describe('render branch tails', () => { it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => { const view = render( - { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( - { it('passes one image group and its message alignment to the renderer', () => { const calls: MessageImagesRenderOwner[] = [] const view = render( - { it('merges consecutive image blocks into one group and splits groups at text', () => { const calls: MessageImagesRenderOwner[] = [] const view = render( - { it('keeps the renderer output at the image block position between text blocks', () => { const calls: MessageImagesRenderOwner[] = [] const view = render( - { cleanup() @@ -35,16 +36,18 @@ describe('ReasoningRow', () => { ['First\r\n \t\r\nNext complete\r\n', 'Next complete'], ['First\n\n\n', 'First'], ])('previews the completed paragraph first line for %j', (text, summary) => { - const view = render() + const view = render() const root = view.container.querySelector('[data-variant="think"]')! expect(root.querySelector('[class*="summaryText"]')?.textContent).toBe(summary) expect(root.hasAttribute('data-preview')).toBe(summary !== '') }) it('retains summaries and expanded Markdown when the work-details mode changes', () => { + const reset = createSnapshotStore(0) + const useDisclosure = bindDisclosure(reset) const mode = createSnapshotStore('detailed') const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) - const view = render() const root = view.container.querySelector('[data-variant="think"]')! const summary = view.getByText('First line') @@ -63,17 +66,21 @@ describe('ReasoningRow', () => { expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(root.hasAttribute('data-preview')).toBe(false) } + act(() => { reset.set(1) }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('Detailed body')).toBeNull() + expect(view.getByRole('button')).toBe(toggle) }) it('keeps a running preview enabled in Compact and hides it on settlement without removing it', () => { const mode = createSnapshotStore('compact') const usePresentation = bindSnapshotSelector(derivePresentationPolicy(mode)) const props = { text: 'First line\n', usePresentation, t } - const view = render() + const view = render() const summary = view.getByText('First line') const root = view.container.querySelector('[data-variant="think"]')! expect(root.hasAttribute('data-preview')).toBe(true) - view.rerender() + view.rerender() expect(root.hasAttribute('data-preview')).toBe(false) expect(view.getByText('First line')).toBe(summary) }) @@ -84,20 +91,20 @@ describe('ReasoningRow', () => { ])('starts collapsed and preserves manual expansion when $kind arrives', (nextBlock) => { const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' } const view = render( - , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false') fireEvent.click(view.getByText('思考')) view.rerender( - , ) expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true') view.rerender( - , ) @@ -109,7 +116,7 @@ describe('ReasoningRow', () => { it('advances on completed paragraph first lines, then restores the settled first line', () => { const view = render( - { .toBe('true') view.rerender( - { const text = 'Inspect the session\nDetails\n\nNewest reasoning tokens\nMore details\n\nChecking boundaries\n' view.rerender( - { expect(view.queryByText('Newest reasoning tokens')).toBeNull() view.rerender( - { it('expands from either Think or the reasoning summary', () => { const view = render( - { }, ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => { const view = render( - { const text = Array.from({ length: 6 }, (_, index) => `${'#'.repeat(index + 1)} Section ${index + 1}`) .join('\n\n') + '\n\nReasoning body.' const view = render( - { it('keeps completed reasoning blocks mounted while the open streaming tail grows', () => { const first = '## Investigation\n\n**Check persistence**\n\n' const view = render( - { const emphasis = view.getByText('Check persistence') const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n') view.rerender( - { it('expanded Think drops the inline summary and renders prose without an IN card', () => { const view = render( - { it('anchors the sticky-header selector: only an open Think row nests the disclosure row under data-expanded and data-open', () => { const view = render( - {label} +} + +const Forward = memo(function Forward(props: { useDisclosure: UseDisclosure; label: string }) { + return +}) + +describe('injected disclosure state', () => { + it('supports local explicit reveal and collapse without a reset source', () => { + function Group({ label }: { label: string }) { + const { expanded, setExpanded } = useDisclosure() + return <> + {expanded ? label : 'Closed'} + + + + } + const view = render() + expect(view.getByText('Closed')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Reveal' })) + expect(view.getByText('First')).toBeTruthy() + view.rerender() + expect(view.getByText('Updated')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'Collapse' })) + expect(view.getByText('Closed')).toBeTruthy() + }) + + it('subscribes only where the forwarded Hook is called and releases those subscriptions', () => { + const reset = createSnapshotStore(0) + const release = vi.fn() + const source: ObservableSnapshot = { + getSnapshot: reset.getSnapshot, + subscribe: vi.fn((listener) => { + const off = reset.subscribe(listener) + return () => { off(); release() } + }), + } + const useDisclosure = bindDisclosure(source) + expect(source.subscribe).not.toHaveBeenCalled() + const view = render() + expect(source.subscribe).toHaveBeenCalledTimes(1) + const button = view.getByRole('button', { name: 'Details' }) + + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + act(() => { reset.set(1) }) + expect(button.getAttribute('aria-expanded')).toBe('false') + expect(view.getByRole('button', { name: 'Details' })).toBe(button) + expect(source.subscribe).toHaveBeenCalledTimes(1) + + view.unmount() + expect(release).toHaveBeenCalledTimes(1) + }) + + it('keeps each disclosure independent while resetting all consumers of the same source', () => { + const reset = createSnapshotStore(0) + const otherReset = createSnapshotStore(0) + const useDisclosure = bindDisclosure(reset) + const useOtherDisclosure = bindDisclosure(otherReset) + const view = render(<> + + + + ) + const first = view.getByRole('button', { name: 'First' }) + const second = view.getByRole('button', { name: 'Second' }) + const other = view.getByRole('button', { name: 'Other Turn' }) + fireEvent.click(first) + expect(first.getAttribute('aria-expanded')).toBe('true') + expect(second.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(second) + fireEvent.click(other) + + act(() => { reset.set(1) }) + expect(first.getAttribute('aria-expanded')).toBe('false') + expect(second.getAttribute('aria-expanded')).toBe('false') + expect(other.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(first) + expect(first.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(first) + expect(first.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(second) + act(() => { reset.set(2) }) + expect(second.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 72abda5bc3..45be7c0b8f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 3e216d9670f4fa58b0590f8cede54927d947d56d -README.zh.md: 6445ee9d62555de36901300809955b7cb227f66f +README.md: e26a277028d41420c4796b4678a2d6897d592872 +README.zh.md: 63d34c640571b0b3e363f6968221d15eea70ad49 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 3e216d9670..e26a277028 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -61,6 +61,8 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool `ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, the session `cwd`, and the owner's callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. +Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it. + ### Cards diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6445ee9d62..63d34c6405 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -61,6 +61,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` `ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、会话 `cwd`,以及属主用于打开文件和检查调用的回调。它递归遍历标准调用块,让 root 与任意深度的 child 经过同一条原子分发路径,不订阅独立的 parent-to-children map。每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 +Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。 + ### 卡片 diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index adb590eb58..d87ccf3ade 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' -import type { OpenFileOptions, ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { OpenFileOptions, ToolCallBlock, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -48,6 +48,8 @@ export interface ToolImagesOwnerProps { /** Standard owner currency supplied to every atomic Tool view. */ export interface ToolCallOwnerProps { + /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */ + useDisclosure: UseDisclosure /** Tool call identity, stable across running and settled forms. */ callId: string /** Wire Tool name and keyed dispatch value. */ diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 6338f89f1c..eb7309aeeb 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -13,8 +13,8 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, cwd, home, inspectCall, loadImage, t, children, -}: Pick & { + renderSlot, callId, toolName, block, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children, +}: Pick & { callId: string toolName: string block: ToolCallBlock @@ -29,8 +29,9 @@ const ToolCall = memo(function ToolCall({ cwd, home, loadImage, + useDisclosure, inspect: inspectCall === undefined ? undefined : () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall, useDisclosure]) const autoReviewDenied = useMemo( () => toolRowModel(toolName, block).autoReviewDenial !== null, [toolName, block], @@ -53,8 +54,8 @@ const ToolCall = memo(function ToolCall({ }) const ToolCallBranch = memo(function ToolCallBranch({ - renderSlot, block, cwd, home, openFile, inspectCall, loadImage, t, -}: Pick & { + renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, +}: Pick & { block: ToolCallBlock home?: string | undefined }) { @@ -68,6 +69,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ cwd={cwd} home={home} inspectCall={inspectCall} + useDisclosure={useDisclosure} loadImage={loadImage} t={t} > @@ -82,6 +84,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ home={home} openFile={openFile} inspectCall={inspectCall} + useDisclosure={useDisclosure} loadImage={loadImage} t={t} /> @@ -99,7 +102,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, cwd, openFile, inspectCall, loadImage, useHostInfo, t, + renderSlot, node, cwd, openFile, inspectCall, loadImage, useDisclosure, useHostInfo, t, }: ToolTreeProps) { const home = useHostInfo(info => info.home) const block = node.data.root @@ -111,6 +114,7 @@ export function ToolCallTree({ home={home} openFile={openFile} inspectCall={inspectCall} + useDisclosure={useDisclosure} loadImage={loadImage} t={t} /> diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 62b91189fc..88d7475e10 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, useCallback, useMemo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, @@ -6,7 +6,7 @@ import { diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { OpenFileOptions, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' @@ -28,6 +28,8 @@ import { ToolDetails, type ToolDetailsModel } from './ToolDetails.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { + /** Subscribe here, where the row owns its expanded body. */ + useDisclosure: UseDisclosure t: TranslateNS<'conversation'> variant: ToolRowVariant /** Wire tool name for tool-owned styling layered over the generic variant. */ @@ -132,8 +134,9 @@ export const ToolRow = memo(function ToolRow({ filePathLine, onOpenFile, inspect, + useDisclosure, }: ToolRowProps) { - const [expanded, setExpanded] = useState(false) + const { expanded, toggle: toggleExpand } = useDisclosure() const terminalLabels = useMemo(() => terminalBlockLabels(t), [t]) const diffLabels = useMemo(() => diffBlockLabels(t), [t]) const readLabels = useMemo(() => readBlockLabels(t), [t]) @@ -178,9 +181,6 @@ export const ToolRow = memo(function ToolRow({ }, [diffBody]) const settledWithCue = state === 'error' || state === 'stopped' const suffix = settledWithCue ? null : summarySuffix ?? diffStat - const toggleExpand = useCallback(() => { - setExpanded(v => !v) - }, []) const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue ? (event: MouseEvent) => { event.stopPropagation() diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index e79e88b1df..c58729723b 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -29,7 +29,7 @@ export interface GenericToolCardProps extends ToolCallOwnerProps { t: ToolTreeProps['t'] } -export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) { +export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: GenericToolCardProps) { const model = toolRowModel(toolName, block, cwd, home) const autoReview = model.autoReviewDenial === null ? null @@ -47,6 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, const singleFile = model.filePath !== undefined return ( /** Summarizes a pending, answered, cancelled, or interrupted question set. */ -export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) { +export function AskQuestionRow({ toolName, block, inspect, useDisclosure, t }: AskQuestionRowProps) { const model = toolRowModel(toolName, block) // Composer verdicts settle the call as specific UserQuestionErrors // (ask_user_question handler): 'ASK_CANCELLED' is the user's own @@ -180,6 +180,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr } return ( toolRowModel(toolName, block), [toolName, block]) // An omitted shell workdir is the session workspace; relative values resolve // against it before reaching the terminal primitive. @@ -52,7 +52,7 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe ? 'error' : model.state const status = stateStatus(state, t) - const [expanded, setExpanded] = useState(false) + const { expanded, toggle: toggleExpand } = useDisclosure() // Failures, persistent-shell results, and spill previews use a generic body; // background acknowledgements and malformed calls remain collapsed. const genericBody = terminal === null @@ -71,9 +71,6 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe ? model.errorSummary ?? normalSummary : state === 'stopped' ? t('bash.stopped') : null const running = state === 'running' - const toggleExpand = useCallback(() => { - setExpanded(v => !v) - }, []) const toggleFromKeyboard = useCallback((event: KeyboardEvent) => { if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index 2c097ab679..a2bfcb447d 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -75,7 +75,7 @@ function detailIcon(toolName: string) { * @param props - Tool call, row actions, and locale supplied by the keyed slot. * @returns A Tool row with structured details or generic input/output. */ -export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, t }: ToolCallViewProps & PropsLocale<'conversation'>) { +export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: ToolCallViewProps & PropsLocale<'conversation'>) { const model = toolRowModel(toolName, block, cwd, home) const locale = document.documentElement.lang const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale]) @@ -85,6 +85,7 @@ export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, t }: : Object.hasOwn(TITLE_KEYS, toolName) ? TITLE_KEYS[toolName as keyof typeof TITLE_KEYS] : model.titleKey return ( /** * Lets users expand an applied file diff and open the reported path. */ -export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t }: FileMutationRowProps) { +export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: FileMutationRowProps) { const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) return ( snapshot?.get(block.callId)) const hasMore = useSession(snapshot => snapshot.hasMore) const diff = useMemo(() => todoDiffModel(block, baseline, hasMore, t), [block, baseline, hasMore, t]) @@ -58,6 +58,7 @@ export function TodoRow({ toolName, block, inspect, useTodoHistory, useSession, const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 } return ( : return ( [0] { return { - callId: 'c1', toolName: 'ask_user_question', block, t, + useDisclosure, callId: 'c1', toolName: 'ask_user_question', block, t, openFile: vi.fn(), sessionId: 's1', useSessions: () => undefined, diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index b758b31350..3e2af18a61 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -109,6 +109,7 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('uiConversation', { events, views, + groups: { register: () => () => {} }, binding: () => ({ target: () => chat, snapshot: conversation }), } as never) ctx.uiSession.provide({ diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 3f56f5f679..3f3579d2e1 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -34,7 +35,7 @@ function listStore() { function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps { return { - callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), + useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(listStore()), t, } as unknown as BashRowProps @@ -43,7 +44,7 @@ function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps { describe('Tool presentation tails', () => { it('ToolRow stopped state retains the business icon and shows a warning summary', () => { const view = render( - } + } title="Bash" summary="s" state="stopped" />, ) expect(view.queryByTestId('icon')).not.toBeNull() @@ -60,7 +61,7 @@ describe('Tool presentation tails', () => { } const props: GenericToolCardProps = { loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, + useDisclosure, callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, } const view = render() expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull() diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index 1d3ed9100b..acca5c7617 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -8,6 +8,7 @@ import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.t import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { detailsCardModel, todosDetail } from '../src/client/tool/models/details-card-model.ts' import { DetailsRow, detailsToolview } from '../src/client/tool/toolviews/details-row.tsx' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' const t = makeTranslate(en, commonEn) const goal = { id: 'goal-1', revision: 2, objective: 'Ship compact cards', phase: 'active', roundsStarted: 2, maxGoalRounds: 8 } @@ -79,7 +80,7 @@ describe('todosDetail', () => { describe('DetailsRow', () => { it('expands fields with the keyboard and keeps Inspect available', () => { const inspect = vi.fn() - render([0]} />) + render([0]} />) expect(screen.queryByText('Rounds')).toBeNull() fireEvent.keyDown(screen.getByRole('button', { expanded: false }), { key: 'Enter' }) expect(screen.getByText('Rounds')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index d7e37d6962..b2569cd261 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -160,7 +161,7 @@ describe('diffCardModel', () => { describe('chat row diff body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t, }) it('the expanded body is the applied diff, capped tighter than the panel', () => { @@ -184,7 +185,7 @@ describe('chat row diff body', () => { // A non-file tool name so the row is not single-file (no path link), and its // args body is the fallback the diff card must not have replaced. const view = render( Promise.reject(new Error('not used'))), t, block: settled({ call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' }, @@ -206,7 +207,7 @@ describe('FileMutationRow diff card', () => { }) const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({ - callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as unknown as FileMutationRowProps) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 74b2a9a127..0de0b1d1b5 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -12,6 +12,7 @@ // still leave the media type and dimensions visible. import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -278,7 +279,7 @@ describe('ReadImageRow keyed toolview', () => { renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'], loader: MessageImageLoader = loadImage, ): Parameters[0] => ({ - callId: 'c1', toolName: 'read_image', block, openFile: vi.fn(), renderSlot, loadImage: loader, + useDisclosure, callId: 'c1', toolName: 'read_image', block, openFile: vi.fn(), renderSlot, loadImage: loader, sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as unknown as Parameters[0]) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index f5ca38418e..e78d7858c4 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -165,7 +166,7 @@ describe('readCallLine', () => { describe('GenericToolCard read body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ @@ -187,7 +188,7 @@ describe('GenericToolCard read body', () => { it('a non-read tool renders the bare row with no read card', () => { const view = render( Promise.reject(new Error('not used'))), t, })} />) @@ -210,7 +211,7 @@ describe('ReadRow keyed toolview', () => { }) const rowProps = (block: RunningToolCall | ToolResultNode): Parameters[0] => ({ - callId: 'c1', toolName: 'read', block, openFile: vi.fn(), + useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as unknown as Parameters[0]) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index cee488b8b6..ee8353c5b1 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -176,7 +177,7 @@ describe('searchCardModel', () => { describe('chat row search body (GenericToolCard fallback)', () => { const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c1', toolName, block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ const toggleRow = (view: { container: HTMLElement }) => { @@ -226,7 +227,7 @@ describe('chat row search body (GenericToolCard fallback)', () => { describe('SearchRow keyed card', () => { const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({ - callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t, + useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t, } as unknown as SearchRowProps) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index d1087f7ce2..2b01dae5f6 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -293,7 +294,7 @@ describe('terminalCardModel', () => { describe('chat row terminal body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ @@ -411,7 +412,7 @@ describe('BashRow terminal card', () => { }) const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({ - callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), + useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as unknown as BashRowProps) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index d6dfe8ad80..5c29325423 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom /** todo_write atomic Tool presentation and its plan-summary model. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -67,7 +68,7 @@ const resultNode = (argsRaw: string, over?: Partial): ToolResult function rowProps(block: unknown): TodoRowProps { return { - callId: 'c1', toolName: 'todo_write', block, + useDisclosure, callId: 'c1', toolName: 'todo_write', block, openFile: vi.fn(), sessionId: 's1', useSessions: () => undefined, diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index f8215ed61c..49d6f66f00 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindDisclosure, useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -32,6 +34,7 @@ function props( return options?.fallback ?? null }) as unknown as ToolTreeProps['renderSlot'] return { + useDisclosure, useSession, renderSlot, node: { @@ -56,6 +59,38 @@ function props( } describe('ToolCallTree', () => { + it('forwards one stable disclosure Hook to nested calls and resets only their open state', () => { + const reset = createSnapshotStore(0) + const useDisclosure = bindDisclosure(reset) + const owners: ToolCallOwnerProps[] = [] + const block = { + ...root('parent', { name: 'custom_parent', argsRaw: '{}' }), + content: [{ type: 'text' as const, text: 'Parent output' }], + subCalls: [{ + ...root('child', { name: 'custom_child', argsRaw: '{}' }), + content: [{ type: 'text' as const, text: 'Child output' }], + }], + } + const view = render() + const rows = [...view.container.querySelectorAll('[data-expandable]')] + expect(rows).toHaveLength(2) + expect(owners.map(owner => owner.useDisclosure)).toEqual([useDisclosure, useDisclosure]) + const dispatched = [...owners] + fireEvent.click(rows[0]!) + fireEvent.click(rows[1]!) + expect(view.getByText('Parent output')).toBeTruthy() + expect(view.getByText('Child output')).toBeTruthy() + + act(() => { reset.set(1) }) + expect(view.queryByText('Parent output')).toBeNull() + expect(view.queryByText('Child output')).toBeNull() + expect([...view.container.querySelectorAll('[data-expandable]')]).toEqual(rows) + expect(owners).toEqual(dispatched) + fireEvent.click(rows[1]!) + expect(view.getByText('Child output')).toBeTruthy() + expect(view.queryByText('Parent output')).toBeNull() + }) + it('owns the root marker and the generic fallback for a window-truncated call', () => { const block = root('w1', null) const view = render() diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index d4749831be..050f8cc42b 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -273,6 +274,7 @@ describe('tool-call-model', () => { describe('ToolRow', () => { const rowProps = { + useDisclosure, t, variant: 'bash' as const, icon: , title: 'Bash', summary: 'List files', bodyRaw: '{"a":1}', state: 'ok' as const, @@ -494,7 +496,7 @@ describe('ToolRow', () => { describe('GenericToolCard', () => { const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - callId: 'c1', toolName, block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t, }) it('renders the classified variant row from the frozen slice', () => { diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 96f66d8cbd..b7981f5056 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -124,7 +125,7 @@ describe('webCardModel', () => { describe('chat row web body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ - callId: block.callId, toolName, block, openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), + useDisclosure, callId: block.callId, toolName, block, openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), }) // WebRow reads only toolName/block off the full runtime share plus the locale // seat; the standard kit is unused, so the cast supplies the owner slice and diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 06110b6325..728efbf906 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -335,6 +335,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi }, useWorkspaces: selector => selector(panelWorkspace), useTurnData: () => undefined, + useDisclosure: () => { throw new Error('unused') }, openSkill: vi.fn(), openFile: () => {}, inspectCall: () => {}, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e4008309a3..8b15ea51e8 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:248', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:269', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:236', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:257', }, { key: 'conversation.chat.node', @@ -269,8 +269,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', - hookContext: 'ConversationLocationDataStore | undefined', - slotInject: 'ChatNodeTurnDataInjected', + hookContext: 'ChatNodeHookContext', + slotInject: 'ChatNodeInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ 'client-ui-chat UserMessageNodeView key \'user\'', @@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:217', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:238', }, { key: 'conversation.chat.turnTail', @@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:242', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:263', }, { key: 'conversation.composer', @@ -1167,7 +1167,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:230', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:251', }, { key: 'conversation.plan-review.actions', @@ -3831,12 +3831,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadImage: MessageImageLoader\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */\n useDisclosure: UseDisclosure\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadIm /* …truncated — full shape in source */', ], ownerPropsReferences: [ 'ChatNodeOwnerProps', 'MessageImageLoader', 'OpenFileOptions', + 'UseDisclosure', ], standardProps: [ 'useResource: UseResource', From e17e1ac801e7a7d21fa75f8043f1063002267d41 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 15:57:58 +0800 Subject: [PATCH 02/25] feat(client): present turn triggers and completed-turn footers --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 10 +-- packages/client/ui-chat/README.zh.md | 10 +-- .../src/client/chat/TurnTailNodeView.tsx | 26 ++++++- .../chat/TurnTriggerNodeView.module.css | 33 +++++++++ .../src/client/chat/TurnTriggerNodeView.tsx | 50 +++++++++++++ .../client/chat/register-node-renderers.ts | 3 + .../ui-chat/src/client/chat/turn-trigger.ts | 72 +++++++++++++++++++ .../src/client/contract/chat-visibility.ts | 1 + .../src/client/contract/turn-process.ts | 1 + .../src/client/conversation-nodes/inbox.ts | 69 ++++++++++-------- .../src/client/conversation-nodes/message.ts | 19 ++++- .../turn-process-presentation.ts | 4 +- .../client/conversation-nodes/turn-tail.ts | 60 +--------------- packages/client/ui-chat/src/client/locale.ts | 22 ++++++ .../tests/chat-node-source.client.spec.ts | 4 +- .../src/client/slot-catalog.ts | 3 +- 17 files changed, 281 insertions(+), 110 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css create mode 100644 packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx create mode 100644 packages/client/ui-chat/src/client/chat/turn-trigger.ts diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 27b73506a4..fcac1b2f93 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: 0b4242b27952fd9570f82284f06e131069aa8e70 -README.zh.md: 1b047a6904acaca696744b1a42594ccceef8a082 +README.md: dfa4d403741dfdf6e4f62313445346d2012d5f58 +README.zh.md: 87853472cfb70f3fda619cb7f8ffc29a0b5496f4 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 0b4242b279..dfa4d40374 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -38,7 +38,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Ordinary Context injection and other command rows remain in Chat; Context injection can fold with the owning Turn's process content. +Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat. ## Command and failure rows @@ -59,7 +59,7 @@ On non-loopback browsers, the preference remains process-local because the setti ## Completed-turn footer -The completed-turn action footer starts 20px below the preceding prose or extension content. +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. ----- @@ -78,12 +78,14 @@ Settings → General → Work details stores `ui-chat.transcriptView` as `compac | Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | | Eligible completed-Turn process | Folded by default | Folded by default | Folded by default | -While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible. +While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible. -The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows. +The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, trigger notices, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the process open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. Switching work-details modes preserves manual expansion. +Non-human Turn triggers are independent notices classified from durable Inbox claims and source fields. Their expanded bodies retain original content, and whole-Turn folding does not hide them. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 1b047a6904..87853472cf 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -38,7 +38,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H ## Chat 隐藏的行 -Chat 在所有工作过程展示模式下都不显示系统提示词行和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。普通上下文注入和其他命令行仍保留在 Chat 中;上下文注入可随所属轮次的过程内容一起收起。 +Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 ## 指令与失败行 @@ -59,7 +59,7 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ## 已完成轮次的页脚 -已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。 +已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。 ----- @@ -78,12 +78,14 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm | 单个思考与工具正文 | 手动展开 | 手动展开 | 手动展开 | | 符合条件的已完成轮次过程 | 默认收起 | 默认收起 | 默认收起 | -轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的上下文注入、思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。 +轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。 -控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。 +控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、触发通知、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。 稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。切换工作过程展示模式会保留手动展开状态。 +非人工轮次触发使用独立通知,依据持久化 Inbox 领取记录及来源字段分类。展开正文保留原始内容,整轮折叠不隐藏通知。 + ----- diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index e8cb78c304..a3d58a8cfe 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -4,12 +4,26 @@ import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps } import { MessageIconActions } from './MessageIconActions.tsx' import { TurnUsagePanel } from './TurnUsagePanel.tsx' import { assistantText } from './turn-assistant.ts' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatSnapshot } from '../contract/snapshot.ts' import css from './TurnTailNodeView.module.css' type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'> & InjectFace +function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean): ChatNode | undefined { + const keys = snapshot.locations.getTurn(turn) + for (let index = keys.length - 1; index >= 0; index--) { + const node = snapshot.nodes.get(keys[index] as string) as ChatNode | undefined + if (node === undefined || node.kind === 'turn-tail' || node.kind === 'turn-process' + || (skipWarning && node.kind === 'turn-max-tokens')) continue + return node + } + return undefined +} + /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage, @@ -17,8 +31,14 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const detailed = usePerformanceUsage(mode => mode) === 'detailed' const data = node.data const hasLaterChatNode = useChat(snapshot => - snapshot.locations.getTurn(data.turn).at(-1) !== node.key) - const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn) + (lastContent(snapshot, data.turn, true)?.anchorSeq ?? -1) > (data.closing?.finalNode.seq ?? data.seq)) + const endsWithResponse = useChat((snapshot) => { + if (snapshot.timeline.turnOrder.at(-1) !== data.turn) return false + const last = lastContent(snapshot, data.turn, false) + const block = last?.kind === 'assistant-step' ? last.data.blocks.findLast(candidate => + (candidate.kind !== 'text' && candidate.kind !== 'reasoning') || candidate.text.trim() !== '') : undefined + return block !== undefined && hasAssistantReplyContent([block]) + }) const turn = node.location.kind === 'turn' || node.location.kind === 'step' ? node.location.turn : undefined @@ -37,7 +57,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({

{tail} > = { + request: IconContextInjectionOutlineRegular, + goal: IconGoalOutlineRegular, + agent: IconPaperPlaneOutlineRegular, + team: IconAgentPresetOutlineRegular, + subagent: IconAgentPresetOutlineRegular, + github: IconBranchOutlineRegular, + webhook: IconGlobeOutlineRegular, + schedule: IconAlarmClockOutlineRegular, + job: IconQueueOutlineRegular, + plugin: IconCordisPluginOutlineRegular, +} + +/** Render recorded trigger attribution above the whole-Turn disclosure. */ +export function TurnTriggerNodeView({ node, t }: ChatNodeViewProps<'turn-trigger'>) { + const [open, setOpen] = useState(false) + const bodyId = useId() + const details = turnTriggerDetails(node.data) + const TriggerIcon = TRIGGER_ICONS[details.icon] + const date = new Date(node.data.time) + const time = formatMessageClock(node.data.time, t) + return ( +
+ + {open &&
+

{t('message.trigger.explanation')}

+
+
} +
+ ) +} diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index 9fa302992d..eec1a124c4 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -12,6 +12,7 @@ import { import { SystemPromptNodeView } from './SystemPromptRow.tsx' import { TurnProcessNodeView } from './TurnProcessNodeView.tsx' import { TurnTailNodeView } from './TurnTailNodeView.tsx' +import { TurnTriggerNodeView } from './TurnTriggerNodeView.tsx' /** * Register this package's business renderers behind the keyed Chat Node seat. @@ -32,6 +33,8 @@ export function registerChatNodeRenderers( { name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-trigger', locale: NS }, TurnTriggerNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'system-prompt', locale: NS }, SystemPromptNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts new file mode 100644 index 0000000000..6555796e98 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts @@ -0,0 +1,72 @@ +/** Presentation of durable non-human messages claimed to begin a Turn. */ +import type { ContextMessageNode } from '../contract/snapshot.ts' +import type { ChatKey } from '../locale.ts' + +/** Existing primitive glyph selected for a Turn trigger's source family. */ +export type TurnTriggerIcon = 'agent' | 'github' | 'goal' | 'job' | 'plugin' | 'request' | 'schedule' | 'subagent' | 'team' | 'webhook' + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {} +} + +function field(source: Record, key: string): string { + return typeof source[key] === 'string' ? source[key] : '' +} + +/** + * Describe a waking message using its source and recognized producer framing. + * @param node - durable context, including the original notification body. + * @returns localized title key and source-family icon. + */ +export function turnTriggerDetails(node: ContextMessageNode): { + title: ChatKey + icon: TurnTriggerIcon +} { + const source = record(node.source) + const kind = field(source, 'kind') + const plugin = field(source, 'plugin') + let title: ChatKey = 'message.trigger.request' + let icon: TurnTriggerIcon = 'request' + switch (kind) { + case 'goal': { + title = 'message.trigger.goal' + icon = 'goal' + break + } + case 'agent-message': + title = 'message.trigger.agent' + icon = 'agent' + break + case 'team-message': + title = 'message.trigger.team' + icon = 'team' + break + case 'subagent-settled': { + title = 'message.trigger.subagent' + icon = 'subagent' + break + } + case 'webhook': { + const github = field(source, 'provider') === 'github' + title = github ? 'message.trigger.github' : 'message.trigger.webhook' + icon = github ? 'github' : 'webhook' + break + } + case 'plugin': + if (plugin === 'schedule') { + title = 'message.trigger.schedule' + icon = 'schedule' + } else if (plugin === 'tool-jobs') { + title = 'message.trigger.job' + icon = 'job' + } else if (plugin === 'cordis-host-runner') { + title = 'message.trigger.plugin' + icon = 'plugin' + } + break + default: + // Custom sources remain visible without attributing unrecorded identity or success. + break + } + return { title, icon } +} diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts index 7e66c15ca5..4fb4ad98de 100644 --- a/packages/client/ui-chat/src/client/contract/chat-visibility.ts +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -9,5 +9,6 @@ import type { ChatNode } from './chat-nodes.ts' export function isVisibleChatNode(node: ChatNode): boolean { return node.visibility === 'visible' && node.kind !== 'system-prompt' + && node.kind !== 'context' && !(node.kind === 'command' && node.data.name === 'permission') } diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index 15c90523bb..d2ef626cc6 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -21,6 +21,7 @@ const TURN_PROCESS_INDEPENDENT_KIND_LIST = [ 'system-prompt', 'user', 'steering', + 'turn-trigger', 'turn-process', 'turn-error', 'turn-max-tokens', diff --git a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts index eed4615e9f..c6492a497b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/inbox.ts @@ -5,6 +5,7 @@ import type { interface InboxIdentity { readonly id: string + readonly source: { readonly kind: string } } interface InboxSplice { @@ -16,7 +17,7 @@ interface InboxSplice { interface PendingSnapshot { readonly kind: 'snapshot' - readonly ids: readonly string[] + readonly ids: readonly InboxIdentity[] } interface PendingSplice { @@ -24,23 +25,25 @@ interface PendingSplice { readonly previous: PendingState readonly start: number readonly removedCount: number - readonly inserted: readonly string[] + readonly inserted: readonly InboxIdentity[] } type PendingState = PendingSnapshot | PendingSplice -/** Persistent next-step state after one durable Inbox splice. */ +/** Persistent Inbox state after one durable Inbox splice. */ export interface InboxState { - /** Persistent splice chain materialized only when a next-step batch is claimed. */ + /** Persistent splice chain materialized only when a batch is claimed. */ readonly pending: PendingState /** Message ids in the current claim, shared until the next claim. */ readonly currentClaimed: ReadonlySet + readonly claimSeq: number + readonly claimedHuman: boolean } const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] } const EMPTY_CURRENT_CLAIMED: ReadonlySet = new Set() -function materializePending(state: PendingState): string[] { +function materializePending(state: PendingState): InboxIdentity[] { const splices: PendingSplice[] = [] let current = state while (current.kind === 'splice') { @@ -56,10 +59,10 @@ function materializePending(state: PendingState): string[] { function withoutInserted( claimed: ReadonlySet, - inserted: readonly string[], + inserted: readonly InboxIdentity[], ): ReadonlySet { let next: Set | undefined - for (const id of inserted) { + for (const { id } of inserted) { if (!claimed.has(id)) continue next ??= new Set(claimed) next.delete(id) @@ -68,7 +71,7 @@ function withoutInserted( } /** - * Apply one next-step splice under the AgentLoop's durable event ordering. + * Apply one Inbox splice under the AgentLoop's durable event ordering. * An entered claim logs its complete message batch before another claim; a * rejected claim logs no messages, so only the current claim can classify a * later `user/message`. @@ -76,16 +79,19 @@ function withoutInserted( function applySplice( previous: ConversationPreviousContext | undefined, splice: InboxSplice, + seq: number, ): InboxState { const priorPending = previous?.state.pending ?? EMPTY_PENDING - const inserted = splice.inserted.map(identity => identity.id) + const inserted = splice.inserted const removedCount = splice.removedCount ?? 0 if (removedCount > 0 && splice.outcome !== 'canceled') { const pending = materializePending(priorPending) const removed = pending.splice(splice.start, removedCount, ...inserted) return { pending: { kind: 'snapshot', ids: pending }, - currentClaimed: new Set(removed), + currentClaimed: new Set(removed.map(message => message.id)), + claimSeq: seq, + claimedHuman: removed.some(message => message.source.kind === 'user'), } } const currentClaimed = withoutInserted( @@ -101,34 +107,37 @@ function applySplice( inserted, }, currentClaimed, + claimSeq: previous?.state.claimSeq ?? -1, + claimedHuman: previous?.state.claimedHuman ?? false, } } -const NEXT_STEP_INBOX_KIND = 'inbox-next-step' - -/** Persistent next-step Inbox state used to classify the current claimed batch as steering. */ -export const nextStepInboxDefinition: ConversationNodeDefinition = { - kind: NEXT_STEP_INBOX_KIND, - match: (event) => { - if (event.type === 'agent/inbox/spliced' && event.data.target === 'next-step') { - return { id: String(event.seq), role: 'start' } - } - return null - }, - start: (_context, match, reader) => { - if (match.event.type !== 'agent/inbox/spliced') { - throw new Error('inbox-next-step start requires agent/inbox/spliced') - } - return applySplice(reader.previous(NEXT_STEP_INBOX_KIND), match.event.data) - }, - update: context => context.state, - publication: () => 'none', +function inboxDefinition(target: 'next-step' | 'next-turn'): ConversationNodeDefinition { + const kind = `inbox-${target}` + return { + kind, + match: event => event.type === 'agent/inbox/spliced' && event.data.target === target + ? { id: String(event.seq), role: 'start' } : null, + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') throw new Error('inbox start requires agent/inbox/spliced') + return applySplice(reader.previous(kind), match.event.data, match.event.seq) + }, + update: context => context.state, + publication: () => 'none', + } } +/** Persistent next-step claims identify messages admitted into a running Turn. */ +export const nextStepInboxDefinition = inboxDefinition('next-step') + +/** Persistent next-turn claims identify messages that wake a new Turn. */ +export const nextTurnInboxDefinition = inboxDefinition('next-turn') + /** - * Register the next-step Inbox state used by Chat message classification. + * Register the Inbox state used by Chat message classification. * @param ctx - owning UI Conversation context. */ export function registerInboxConversationNodes(ctx: Context): void { ctx.uiConversation.events.register(nextStepInboxDefinition) + ctx.uiConversation.events.register(nextTurnInboxDefinition) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 22ebf7cb08..f3c8fa5866 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -21,7 +21,7 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode { readonly skillNames?: readonly string[] } -type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | (ContextMessageNode & { readonly waking?: boolean }) declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -31,6 +31,8 @@ declare module '../contract/chat-nodes.ts' { steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode + /** Non-human input that starts a Turn. */ + 'turn-trigger': ContextMessageNode } } @@ -58,8 +60,18 @@ export const messageDefinition: ConversationNodeDefinition = { if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message') const event = match.event if (event.data.source.kind !== 'user') { + const nextTurn = reader.previous('inbox-next-turn')?.state + const nextStep = reader.previous('inbox-next-step')?.state + const location = match.location + const turnStart = location.kind === 'step' ? location.turn.start?.seq : undefined + const idleSteer = location.kind === 'step' && location.step.step === 1 + && turnStart !== undefined && (nextStep?.claimSeq ?? -1) > turnStart + && (nextTurn?.claimSeq ?? -1) < turnStart && nextStep?.claimedHuman === false + && nextStep.currentClaimed.has(String(event.data.id)) + return { kind: 'context', + waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer, seq: event.seq, time: event.time, content: event.data.content, @@ -90,7 +102,10 @@ export const messageDefinition: ConversationNodeDefinition = { update: context => context.state, buildViewNode: (context) => { if (context.state === undefined) return null - return chatNode(context, context.state.kind, context.state.seq, context.state) + const waking = context.state.kind === 'context' + && context.start?.event.type === 'user/message' + && context.state.waking === true + return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) }, } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index d337954772..77995c5089 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -40,7 +40,7 @@ function derivePresentation( let openingHumanAnchor: number | undefined for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined - if ((node?.kind === 'user' || node?.kind === 'steering') + if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger') && node.anchorSeq < spec.controlAnchorSeq) { openingHumanAnchor = Math.min(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq) } @@ -51,7 +51,7 @@ function derivePresentation( for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined if (node === undefined || !isVisibleChatNode(node) || node.kind === 'turn-process') continue - if ((node.kind === 'user' || node.kind === 'steering') + if ((node.kind === 'user' || node.kind === 'steering' || node.kind === 'turn-trigger') && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor) && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { compactAnswer = false diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 1cccf210f3..5f5766b149 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts @@ -3,14 +3,12 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client' import type { AssistantChatData, FinalAssistantChatData, TurnTailChatData, } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' -import { toAssistantBlocks } from './event-projection.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -31,29 +29,10 @@ interface TurnTailState { readonly end?: ConversationMatch } -interface StepEvidence { - readonly streamedText: boolean - readonly finalized: boolean -} - function isSessionEvent(event: ConversationMatch['event']): event is SessionEvent { return event.type !== 'assistant/live-chunk' } -function hasTextAssistant(event: Parameters[0]): boolean { - return event.type === 'assistant/message' - && event.surfaceOp === 'append' - && toAssistantBlocks(event.data.message.content) - .some(block => block.kind === 'text' && block.text.trim() !== '') -} - -function chunkHasText(chunk: StreamChunk): boolean { - if (chunk.type === 'text-delta') return chunk.text.trim() !== '' - return chunk.type === 'block-end' - && chunk.block.type === 'text' - && chunk.block.text.trim() !== '' -} - function turnCoordinates(event: Parameters[0]): { readonly turn: number readonly step?: number @@ -71,43 +50,6 @@ function turnCoordinates(event: Parameters[ return undefined } -function closingAnchor(context: ConversationNodeContext): number { - let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq - ?? context.start?.event.seq - ?? context.matches[0]?.event.seq - ?? 0 - const steps = new Map() - for (const match of context.matches) { - const event = match.event - if (event.type === 'turn/end') continue - const coordinates = turnCoordinates(event) - if (coordinates?.step === undefined) continue - const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false } - if (event.type === 'assistant/live-chunk') { - steps.set(coordinates.step, { - ...previous, - streamedText: previous.streamedText || chunkHasText(event.data.chunk), - }) - continue - } - if (event.type === 'assistant/message') { - steps.set(coordinates.step, { streamedText: false, finalized: true }) - if (hasTextAssistant(event)) { - anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup - } - continue - } - if (event.type === 'llm/retry') { - steps.set(coordinates.step, { streamedText: false, finalized: false }) - continue - } - if (event.type === 'step/end' && previous.streamedText && !previous.finalized) { - anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup - } - } - return anchor -} - function turnLocation(context: ConversationNodeContext): TurnLocation | undefined { const location = context.start?.location ?? context.matches[0]?.location return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined @@ -193,7 +135,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') - return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) + return data === undefined ? null : chatNode(context, 'turn-tail', data.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup, data) }, } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index a0440b4682..572ed93037 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -5,6 +5,17 @@ export const NS = 'chat' /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { + 'message.trigger.request': '收到执行请求', + 'message.trigger.goal': '继续执行目标', + 'message.trigger.agent': '收到任务消息', + 'message.trigger.team': '收到团队消息', + 'message.trigger.subagent': '子任务状态更新', + 'message.trigger.github': '收到 GitHub 事件', + 'message.trigger.webhook': '收到外部事件', + 'message.trigger.schedule': '定时任务', + 'message.trigger.job': '后台任务状态更新', + 'message.trigger.plugin': '插件状态更新', + 'message.trigger.explanation': '这条通知触发了本轮回复。', 'view.chat': '对话', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}秒', @@ -120,6 +131,17 @@ export type ChatKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { + 'message.trigger.request': 'Execution requested', + 'message.trigger.goal': 'Continuing goal', + 'message.trigger.agent': 'Task message received', + 'message.trigger.team': 'Team message received', + 'message.trigger.subagent': 'Subtask status updated', + 'message.trigger.github': 'GitHub event received', + 'message.trigger.webhook': 'External event received', + 'message.trigger.schedule': 'Scheduled task', + 'message.trigger.job': 'Background task updated', + 'message.trigger.plugin': 'Plugin status updated', + 'message.trigger.explanation': 'This notification triggered this response.', 'view.chat': 'Chat', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}s', diff --git a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts index 17cd7942b6..5f12d9f9b7 100644 --- a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts @@ -52,9 +52,7 @@ describe('Chat Node keyed sources', () => { expect(input.order).toBe(next.order) expect(input.changedTurns).toEqual([7]) expect(input.changedTurnOrders).toEqual([]) - expect(input.readPosition(context.key as typeof input.order[number])).toEqual({ - turn: undefined, previous: user.key, next: undefined, - }) + expect(input.readPosition(context.key as typeof input.order[number])).toBeUndefined() expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined() expect(input.changes.find(change => change.current.key === user.key)).toEqual({ previous: user, current: next.nodes.get(user.key), diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 8b15ea51e8..7e89ff22ec 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run', hookContext: 'ChatNodeHookContext', slotInject: 'ChatNodeInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', @@ -276,6 +276,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat UserMessageNodeView key \'user\'', 'client-ui-chat UserMessageNodeView key \'steering\'', 'client-ui-chat ContextMessageNodeView key \'context\'', + 'client-ui-chat TurnTriggerNodeView key \'turn-trigger\'', 'client-ui-chat SystemPromptNodeView key \'system-prompt\'', 'client-ui-chat AssistantNodeView key \'assistant-step\'', 'client-ui-chat CommandNodeView key \'command\'', From 1bcb633c3f70402492839b23773ce74a7d70ae57 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 20:23:43 +0800 Subject: [PATCH 03/25] feat(chat): add grouped work details and stable history paging --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 33 +- packages/client/ui-chat/README.zh.md | 33 +- packages/client/ui-chat/package.json | 1 + .../src/client/chat/AssistantMarkdown.tsx | 10 +- .../src/client/chat/AssistantNodeView.tsx | 3 +- .../src/client/chat/ChatGroupSeat.module.css | 127 ++++++- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 202 +++++++++- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 35 +- .../src/client/chat/ChatView.module.css | 59 --- .../ui-chat/src/client/chat/ChatView.tsx | 54 +-- .../chat/TurnProcessNodeView.module.css | 29 +- .../src/client/chat/TurnProcessNodeView.tsx | 102 +++--- .../ui-chat/src/client/chat/message-chrome.ts | 22 ++ .../ui-chat/src/client/chat/step-process.ts | 31 ++ .../src/client/chat/use-chat-navigation.ts | 59 ++- .../src/client/chat/use-chat-scroll.ts | 15 +- .../src/client/chat/use-chat-viewport.ts | 132 ++++++- .../src/client/contract/process-groups.ts | 23 ++ .../ui-chat/src/client/contract/slots.ts | 1 + .../src/client/contract/turn-process.ts | 12 + .../conversation-nodes/README.i18n.yaml | 6 + .../src/client/conversation-nodes/README.md | 276 ++++++++++++++ .../client/conversation-nodes/README.zh.md | 276 ++++++++++++++ .../chat-snapshot-builder.ts | 2 +- .../conversation-nodes/process-activity.ts | 122 ++++++ .../conversation-nodes/process-groups.ts | 299 +++++++++++++++ .../src/client/conversation-nodes/register.ts | 2 + .../turn-process-presentation.ts | 2 +- .../client/conversation-nodes/turn-process.ts | 2 +- packages/client/ui-chat/src/client/index.ts | 1 + packages/client/ui-chat/src/client/locale.ts | 66 +++- .../ui-chat/src/client/presentation-policy.ts | 7 +- .../tests/apply-inject.client.spec.tsx | 19 +- .../tests/chat-snapshot-fixture.client.ts | 13 +- .../ui-chat/tests/chat-view.client.spec.tsx | 346 ++++++++++++++++-- .../tests/chat-viewport.client.spec.ts | 290 ++++++++++++++- .../tests/process-groups.client.spec.ts | 336 +++++++++++++++++ packages/client/ui-chat/tsconfig.client.json | 3 + .../src/client/slot-catalog.ts | 10 +- pnpm-lock.yaml | 3 + 41 files changed, 2711 insertions(+), 357 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/step-process.ts create mode 100644 packages/client/ui-chat/src/client/contract/process-groups.ts create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/README.md create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/README.zh.md create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts create mode 100644 packages/client/ui-chat/tests/process-groups.client.spec.ts diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index fcac1b2f93..3a59ce8e2c 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: dfa4d403741dfdf6e4f62313445346d2012d5f58 -README.zh.md: 87853472cfb70f3fda619cb7f8ffc29a0b5496f4 +README.md: 777cc097326cb0d7b75e1c7eb9d35e0060d3476a +README.zh.md: d5490216e34b62da778a28493285396d89a66a84 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index dfa4d40374..777cc09732 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -66,34 +66,20 @@ The completed-turn action footer follows the recorded Turn end. Its action row s ## Turn Process Folding -Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice unless the enclosing Turn hides the row. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. While streaming, all modes preview the latest paragraph whose first line ends with a newline. Further text in that paragraph does not change the preview; an unfinished single line has no preview. One or more blank lines separate paragraphs. The one-line preview fades at the right edge. After settlement, Compact hides the preview, while Detailed and Expanded show the full text's first line. - -Reasoning rows retain the disclosure's callback, icon, and unchanged content references. Each reasoning row selects its preview visibility from the display policy; mode changes toggle CSS display without unmounting the collapsed summary or updating its parent Assistant renderer. Full Markdown elements are created only while expanded. - -Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back; it is not offered as an option. - -| Behavior | Compact | Detailed | Expanded | -|---|---|---|---| -| Settled reasoning preview | Think title only | First line | First line | -| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | -| Eligible completed-Turn process | Folded by default | Folded by default | Folded by default | - -While a Turn is open, its process rows remain in the transcript; individual reasoning and tool disclosures retain their own expansion state. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block. If that Turn's `turn/start` is loaded, preceding reasoning, earlier Assistant material, Tool rows, and Retry rows fold by default. Folding eligibility is per Turn: a complete Turn can fold while Load earlier remains available, and a Turn whose start is missing keeps its process content visible until that start arrives. A closed Turn with no final answer also keeps all process evidence visible. - -The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls. Zero-valued segments are omitted; the Tool and subagent figures are mutually exclusive, and Context injection contributes no count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, trigger notices, error, max-token, and turn-tail rows stay outside. A newly available process control preserves existing row order, with opening human input before the control and process rows. - -Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the process open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed. Switching work-details modes preserves manual expansion. - -Non-human Turn triggers are independent notices classified from durable Inbox claims and source fields. Their expanded bodies retain original content, and whole-Turn folding does not hide them. +Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. ----- ## Grouped rendering -Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for reading-position restoration; Turn navigation can still address the original Node key and land on its first visible part. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped. +Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries. -The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition. +`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. + +The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition. + +Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted. The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. @@ -111,7 +97,9 @@ While the pointer is outside the rail, automatic follow keeps the rail still whe
Scroll implementation — click to expand -`useChatViewport` owns turn-aware DOM reads, clamped writes, and native events. `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and history-prepend preservation. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test. +`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space. + +`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reader input releases the paging anchor; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
@@ -130,7 +118,6 @@ None; Chat presentation does not assemble or mutate provider requests. -- **Secondary process grouping is not implemented** — Detailed and Expanded currently display the same Chat content. Expanded retains whole-Turn folding and does not automatically open individual reasoning or tool bodies. - **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 87853472cf..d5490216e3 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -66,34 +66,20 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通 ## 轮次过程折叠 -每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择,除非外层轮次隐藏了该行。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式输出期间,三档都预览最近一个首行以换行符结束的段落;该段落后续文本不会改变预览,未写完的单行不显示预览。一个或多个空行分隔段落。预览保持单行,右侧渐隐。结算后,「简洁」隐藏预览,「详细」和「完全展开」显示全文首行。 - -思考行保留折叠控件的回调、图标和未变化内容的引用。每个思考行从展示策略中选择预览可见性;模式切换只改变 CSS 显示状态,不卸载折叠摘要,也不更新其父级 Assistant 渲染器。完整 Markdown 元素仅在展开时创建。 - -「设置 → 通用设置 → 工作过程展示」将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写,也不再作为选项提供。 - -| 行为 | 简洁 | 详细 | 完全展开 | -|---|---|---|---| -| 已完成思考的预览 | 只显示「思考」标题 | 首行 | 首行 | -| 单个思考与工具正文 | 手动展开 | 手动展开 | 手动展开 | -| 符合条件的已完成轮次过程 | 默认收起 | 默认收起 | 默认收起 | - -轮次打开期间,过程行保留在 transcript 中,单个思考与工具控件各自保留开合状态。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界。若该轮次的 `turn/start` 已加载,边界之前的思考、较早 Assistant 内容、工具行与重试行默认收起。折叠资格逐轮判断:即使仍可「加载更早」,完整轮次也能收起;缺少起点的轮次则保持过程内容可见,直到该起点加载。关闭时没有最终答案的轮次也保留全部过程证据。 - -控件展示覆盖整个轮次的非 subagent 工具调用数、最终答案之前带回复内容的 Assistant 消息数和 subagent 委派数。值为 0 的分段省略;工具调用与 subagent 两项互斥,上下文注入不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、触发通知、错误、最大 token 与 turn-tail 行留在过程之外。新的过程控件保留既有行的相对顺序,开场人工输入位于控件和过程行之前。 - -稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域存储只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起。切换工作过程展示模式会保留手动展开状态。 - -非人工轮次触发使用独立通知,依据持久化 Inbox 领取记录及来源字段分类。展开正文保留原始内容,整轮折叠不隐藏通知。 +工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 ----- ## 分组渲染 -Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 +Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。 -组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。 +`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 + +过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 + +每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 @@ -111,7 +97,9 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
滚动实现——点击展开 -`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入与原生事件。`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转与历史前插时的位置保持。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。 +`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。 + +`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。读者输入释放分页锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
@@ -130,7 +118,6 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 -- **二级过程分组尚未实现**——「详细」与「完全展开」目前展示相同的 Chat 内容。「完全展开」仍保留整轮折叠,不会自动打开单个思考或工具正文。 - **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 0864f6e239..8ce7022573 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 8170413bad..1dd235d3c1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -23,6 +23,8 @@ export function localPathMediaUrl(base: string, value: string): string | undefin } export interface AssistantMarkdownProps { + /** Render only the requested business portion, preserving original block indexes. */ + groupPart?: string | undefined /** Stable Hook forwarded to each independently expandable reasoning block. */ useDisclosure: UseDisclosure blocks: readonly AssistantBlock[] @@ -45,7 +47,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, renderMessageImages, useDisclosure, + blocks, streaming, interrupted, renderMessageImages, groupPart, useDisclosure, reasoningHidden = false, usePresentation, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object @@ -67,6 +69,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ for (let i = 0; i < blocks.length; i++) { const block = blocks[i] if (block === undefined) continue + if (groupPart === 'reasoning' && block.kind !== 'reasoning') continue + if (groupPart === 'response' && block.kind === 'reasoning') continue switch (block.kind) { case 'text': rendered.push( @@ -134,7 +138,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
{rendered} - {interrupted && {t('message.stopped')}} + {interrupted && (groupPart === undefined || groupPart === 'response' + || !blocks.some(block => block.kind !== 'reasoning' && block.kind !== 'tool-call')) + && {t('message.stopped')}}
) diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index 6073a5885d..76ce74ef02 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -7,7 +7,7 @@ type AssistantNodeViewProps = ChatNodeViewProps<'assistant-step'> & InjectFace

* { flex-shrink: 0; } +.content > :not([hidden]):not(:empty) ~ :not([hidden]):not(:empty) { + margin-top: var(--dsh-chat-flow-gap, 8px); +} +.expandedBody { + --dsh-chat-flow-gap: 16px; + max-height: none; + overflow: visible; + scrollbar-gutter: auto; } diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index a5af681ef5..4597238c66 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -1,30 +1,204 @@ -/** Stable parent for one group's Node references, independent of display mode. */ -import { memo, type ComponentProps } from 'react' -import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** Stable process container; display policy changes visibility, never member parents. */ +import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react' +import { + IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, + IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, + IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, + IconThinkOutlineRegular, TextShimmer, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ProcessActivity } from '../contract/process-groups.ts' +import type { ChatStoreState } from '../contract/store.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { storedTurnProcessEntry } from '../stores.ts' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { chatRenderKey } from './render-entry.ts' +import { processTitle } from './step-process.ts' +import { useSearchableHidden } from './searchable-hidden.ts' +import { useDisclosure } from './use-disclosure.ts' import css from './ChatGroupSeat.module.css' -type ChatGroupSeatProps = Omit, 'nodeKey' | 'groupPart'> & { +type SeatProps = Omit, 'nodeKey' | 'groupPart'> +type ChatGroupSeatProps = SeatProps & { readonly groupKey: GroupKey readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } +interface ProcessScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean } +const PROCESS_SCROLL_AT_REST: ProcessScrollEdges = { canScrollUp: false, canScrollDown: false } +const PROCESS_TITLE_MINIMUM_MS = 150 -/** Render the group's members without moving them between React parents on mode changes. */ +type ProcessTitleActivity = ProcessActivity | 'thinking' + +interface LiveProcessTitle { + readonly activity: ProcessTitleActivity + readonly detail: string +} + +const PROCESS_ICONS: Record = { + thinking: , + read: , + search: , + edit: , + commands: , + code: , + webSearch: , + webFetch: , + subagents: , + plan: , + questions: , + tools: , +} + +function processScrollEdges(element: HTMLElement): ProcessScrollEdges { + return { + canScrollUp: element.scrollTop > 1, + canScrollDown: element.scrollTop < element.scrollHeight - element.clientHeight - 1, + } +} + +function sameProcessScrollEdges(left: ProcessScrollEdges, right: ProcessScrollEdges): boolean { + return left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown +} + +function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean { + return left.activity === right.activity && left.detail === right.detail +} + +function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle { + const [displayed, setDisplayed] = useState(desired) + const displayedRef = useRef(displayed) + const desiredRef = useRef(desired) + const displayedAtRef = useRef(Date.now()) + useEffect(() => { + desiredRef.current = desired + if (!active || sameLiveProcessTitle(displayedRef.current, desired)) return + const remaining = PROCESS_TITLE_MINIMUM_MS - (Date.now() - displayedAtRef.current) + const commit = (): void => { + const next = desiredRef.current + displayedRef.current = next + displayedAtRef.current = Date.now() + setDisplayed(next) + } + if (remaining <= 0) { + commit() + return + } + const timer = setTimeout(commit, remaining) + return () => { clearTimeout(timer) } + }, [active, desired.activity, desired.detail]) + return active ? displayed : desired +} + + +const GroupMembers = memo(function GroupMembers({ members, ...props }: SeatProps & { + readonly members: readonly NodeReference[] +}) { + return members.map(member => ) +}) + +const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatGroup, usePresentation, t, open, bodyId, toggle }: { + readonly groupKey: GroupKey + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] + readonly usePresentation: ChatViewSlotProps['usePresentation'] + readonly t: ChatViewSlotProps['t'] + readonly open: boolean + readonly bodyId: string + readonly toggle: () => void +}) { + const data = useChatGroup(groupKey, group => group?.data) + const detailed = usePresentation(policy => policy.liveProcessDetail) + const live = useStableLiveProcessTitle({ + activity: data?.summary.running ?? 'thinking', + detail: data?.summary.runningDetail ?? '', + }, data !== undefined && !data.closed) + if (data === undefined) return null + const label = data.closed ? processTitle(data.summary, true, t) : t(`message.stepProcess.${live.activity}`) + const detail = detailed && !data.closed ? live.detail : '' + const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}` + const activity = data.closed ? data.summary.counts[0]?.kind ?? 'thinking' : live.activity + return ( + + ) +}) + +/** Render a process group with local disclosure and the existing outer-Turn visibility. */ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) { const members = useChatGroup(groupKey, group => group?.members) + const turn = useChatGroup(groupKey, group => group?.data.turn) + const grouped = props.usePresentation(policy => policy.stepGrouping !== 'none') + const foldCompleted = props.usePresentation(policy => policy.foldCompletedTurns) + const { expanded: open, setExpanded: setOpen, toggle } = useDisclosure() + const firstKey = members?.[0]?.key ?? '' + const presentation = props.useChatNodeProcess(firstKey) + const turnLocation = props.useChatNode(firstKey, (node) => { + const location = node?.location + return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined + }) + const reason = turnLocation?.end?.data.reason.kind + const alwaysOpen = presentation?.turnClosed === false || reason === 'aborted' || reason === 'error' + const spec = presentation?.spec + const selectStored = useCallback((state: Readonly) => turn === undefined + ? undefined : storedTurnProcessEntry(state, turn), [turn]) + const stored = props.useStore(selectStored) + const outerHidden = foldCompleted && presentation?.turnClosed === true && spec !== undefined + && !alwaysOpen && stored?.answerStep !== (spec.answerStep ?? 0) + const revealOuter = useCallback(() => { + if (spec !== undefined && !alwaysOpen) props.actions.setTurnProcessOpen(spec.turn, spec.answerStep ?? 0, true) + }, [props.actions, spec, alwaysOpen]) + const rootRef = useSearchableHidden(outerHidden, revealOuter) + useEffect(() => { + if (outerHidden && rootRef.current?.hasAttribute('hidden')) setOpen(false) + }, [outerHidden, rootRef, setOpen]) + const reveal = useCallback(() => { setOpen(true) }, [setOpen]) + const bodyRef = useSearchableHidden(grouped && !open, reveal) + const contentRef = useRef(null) + const bodyId = useId() + const [edges, setEdges] = useState(PROCESS_SCROLL_AT_REST) + const sync = useCallback(() => { + const body = bodyRef.current + const next = body === null || body.closest('[hidden], [data-group-expanded-mode]') !== null + ? PROCESS_SCROLL_AT_REST : processScrollEdges(body) + setEdges(previous => sameProcessScrollEdges(previous, next) ? previous : next) + }, [bodyRef]) + // The content box reports growth even when the scroll body remains height-capped. + useLayoutEffect(() => { + sync() + const body = bodyRef.current + if (body === null || !open || typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(sync) + observer.observe(body) + if (contentRef.current !== null) observer.observe(contentRef.current) + return () => { observer.disconnect() } + }, [bodyRef, open, sync]) if (members === undefined) return null + const classes = [css.body, !grouped ? css.expandedBody : '', + grouped && edges.canScrollUp ? css.fadeTop : '', grouped && edges.canScrollDown ? css.fadeBottom : ''] return ( -

- {members.map(member => ( - - ))} +
+ +
+
+ +
+
) }) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 70dc4e9d45..71df6e793d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -5,7 +5,7 @@ import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@de import type { ChatNodeHookContext, ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatNodeStore } from '../contract/snapshot.ts' -import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { TURN_PROCESS_INDEPENDENT_KINDS, turnProcessAlwaysOpen } from '../contract/turn-process.ts' import { storedTurnProcessEntry } from '../stores.ts' import { useSearchableHidden } from './searchable-hidden.ts' import css from './ChatView.module.css' @@ -56,47 +56,49 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ ? undefined : storedTurnProcessEntry(state, processSpec.turn)) const processEntry = processSpec !== undefined - && processSpec.answerStep !== null - && storedEntry?.answerStep === processSpec.answerStep + && storedEntry?.answerStep === (processSpec.answerStep ?? 0) ? storedEntry : undefined - const processOpen = processEntry !== undefined + const liveProcess = processPresentation !== undefined && !processPresentation.turnClosed + const alwaysOpen = liveProcess || turnProcessAlwaysOpen(routedNode) + const processOpen = alwaysOpen || processEntry !== undefined const setOpen = useCallback((open: boolean) => { - if (processSpec !== undefined && processSpec.answerStep !== null) { - actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep, open) + if (processSpec !== undefined && !alwaysOpen) { + actions.setTurnProcessOpen(processSpec.turn, processSpec.answerStep ?? 0, open) } - }, [actions, processSpec]) + }, [actions, processSpec, alwaysOpen]) const foldCompleted = usePresentation(policy => policy.foldCompletedTurns) - // Folding is decided per Turn: the Turn is closed and its start is loaded. + // A loaded end makes a partial historical Turn eligible without its start. const processWindowReady = processSpec !== undefined && processPresentation !== undefined && foldCompleted - && processSpec.answerAnchorSeq !== null && processPresentation.turn === processSpec.turn - && processPresentation.turnClosed - && processPresentation.turnStarted + && (processPresentation.turnStarted || processPresentation.turnClosed) const processMember = routedNode !== undefined && processWindowReady && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) && routedNode.anchorSeq >= processSpec.processStartSeq - && routedNode.anchorSeq < processSpec.answerAnchorSeq + && (liveProcess || processSpec.answerAnchorSeq === null || routedNode.anchorSeq < processSpec.answerAnchorSeq + || (groupPart === 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep)) const processAnswer = routedNode !== undefined && processWindowReady + && !liveProcess + && groupPart !== 'reasoning' && routedNode.kind === 'assistant-step' && routedNode.data.step === processSpec.answerStep const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer const foldable = processWindowReady - && (processMember || (ownsDisclosure - && (processPresentation.hasExternalProcess || processSpec.inlineReasoning))) + && (liveProcess || processMember || ownsDisclosure) const turnProcess = useMemo(() => processSpec === undefined ? undefined : { spec: processSpec, foldable, + hasContent: processPresentation?.hasExternalProcess === true || processSpec.inlineReasoning, open: processOpen, setOpen, }, [ - foldable, processOpen, processSpec, setOpen, + foldable, processOpen, processSpec, processPresentation?.hasExternalProcess, setOpen, ]) const controllerInactive = routedNode?.kind === 'turn-process' && !foldable @@ -139,13 +141,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ // keyed-slot entry passed alongside that same Node. TypeScript does not // distribute an object containing a union into a union of objects itself. const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner - const flowKey = groupPart === undefined ? routedNode.key : JSON.stringify([routedNode.key, groupPart]) + const flowKey = groupPart === undefined || groupPart === 'response' ? routedNode.key : JSON.stringify([routedNode.key, groupPart]) return (
Date.now()) - // Anchored to turn/start so a mid-turn reload keeps the real - // elapsed time and the final footer's Ran-for label matches this clock. - const anchor = startTime ?? mountedAt - const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor)) - useEffect(() => { - const tick = (): void => { - setElapsedMs(Math.max(0, Date.now() - anchor)) - } - tick() - const id = setInterval(tick, 1000) - return () => { clearInterval(id) } - }, [anchor]) - // Short turns keep the plain label; the clock only appears once the turn - // has clearly been running for a while. - const showClock = elapsedMs >= 15_000 - return ( -
- {t('chat.deepDiving')} - {showClock && ( - - {formatRunDuration(elapsedMs, t)} - - )} -
- ) -} - type ChatNodeListProps = Omit, 'nodeKey' | 'groupPart'> & { readonly entries: readonly RenderEntry[] readonly useChatGroup: ChatViewSlotProps['useChatGroup'] @@ -143,7 +98,6 @@ export function ChatView({ () => mergeTurnRailItems(turnNavigationItems, turnOutline), [turnNavigationItems, turnOutline], ) - const timeline = useChat(s => s.timeline) const inbox = useProjection('inbox') as unknown as InboxState | undefined // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -206,7 +160,6 @@ export function ChatView({ owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], ) - const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const firstKey = order[0] const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null @@ -271,9 +224,6 @@ export function ChatView({ {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} - {/* Turn-level loading signal: rides the whole running turn (first-token - wait, tool execution, streaming) so it never flickers per step. */} - {running && } {pendingSteering.map(item => ( ) { if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state') - if (!turnProcess.foldable) return null const open = turnProcess.open - const labels: string[] = [] - if (node.data.toolCallCount > 0) { - labels.push(t( - node.data.toolCallCount === 1 - ? 'message.turnProcess.toolCalls.one' - : 'message.turnProcess.toolCalls.other', - { count: node.data.toolCallCount }, - )) - } - if (node.data.messageCount > 0) { - labels.push(t( - node.data.messageCount === 1 - ? 'message.turnProcess.messages.one' - : 'message.turnProcess.messages.other', - { count: node.data.messageCount }, - )) - } - if (node.data.subagentCount > 0) { - labels.push(t( - node.data.subagentCount === 1 - ? 'message.turnProcess.subagents.one' - : 'message.turnProcess.subagents.other', - { count: node.data.subagentCount }, - )) - } - const label = labels.length === 0 - ? t('message.turnProcess.thoughtForAWhile') - : labels.join(t('message.turnProcess.separator')) + const turn = node.location.kind === 'turn' || node.location.kind === 'step' + ? node.location.turn + : undefined + const [now, setNow] = useState(Date.now) + const ticking = turnProcess.foldable && turn?.status === 'open' + useEffect(() => { + if (!ticking) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [ticking]) + if (!turnProcess.foldable) return null + const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node) + const running = turn?.status === 'open' + const reason = turn?.end?.data.reason.kind + const elapsedMs = turn?.start === undefined ? undefined + : Math.max(1000, (turn.end?.time ?? now) - turn.start.time) + const duration = elapsedMs === undefined ? undefined + : running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t) + // Other end reasons retain elapsed time; only cancellation and failure replace it. + const label = running + ? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration }) + : reason === 'aborted' ? t('message.stopped') + : reason === 'error' ? t('message.turnProcess.failed') + : duration === undefined ? t('message.turnProcess.worked') + : t('message.turnProcess.took', { duration }) + const announcement = running ? t('chat.deepDiving') + : reason === 'aborted' ? t('message.stopped') + : reason === 'error' ? t('message.turnProcess.failed') + : t('message.turnProcess.worked') return ( - + <> + {announcement} + + ) }) diff --git a/packages/client/ui-chat/src/client/chat/message-chrome.ts b/packages/client/ui-chat/src/client/chat/message-chrome.ts index 36c4a3f0bd..7f6826fc96 100644 --- a/packages/client/ui-chat/src/client/chat/message-chrome.ts +++ b/packages/client/ui-chat/src/client/chat/message-chrome.ts @@ -8,6 +8,10 @@ export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> /** The elapsed-duration share of the conversation dictionary. */ export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes' | 'duration.hours'> + +/** Refresh interval for whole-second live run clocks. */ +export const LIVE_RUN_CLOCK_INTERVAL_MS = 1000 + function pad2(n: number): string { return String(n).padStart(2, '0') } @@ -55,6 +59,24 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string { : t('duration.seconds', { seconds }) } +/** + * Localized live elapsed time without padded seconds or early rollover. + * @param ms - Elapsed duration in milliseconds (negatives clamp to zero). + * @param t - Translate seat supplying the duration templates. + * @returns Whole seconds without a leading zero; minutes start at 60 seconds + * and hours start at exactly 60 minutes. + */ +export function formatLiveRunDuration(ms: number, t: RunDurationTranslate): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor(totalSeconds / 60) % 60 + const seconds = String(totalSeconds % 60) + if (hours > 0) return t('duration.hours', { hours, minutes: pad2(minutes), seconds }) + return minutes > 0 + ? t('duration.minutes', { minutes, seconds }) + : t('duration.seconds', { seconds }) +} + /** * Decode-throughput figure: whole tokens from ten up, one decimal below. * @param tps - Tokens per second. diff --git a/packages/client/ui-chat/src/client/chat/step-process.ts b/packages/client/ui-chat/src/client/chat/step-process.ts new file mode 100644 index 0000000000..afb1b0f41d --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/step-process.ts @@ -0,0 +1,31 @@ +/** Localized titles for projected step-process activity. */ +import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ProcessActivitySummary } from '../contract/process-groups.ts' + +/** + * Compose localized abstract status or the top three completed categories without counts. + * @param summary - ranked work and phase evidence for this range. + * @param closed - whether a following reply or turn boundary closed the range. + * @param t - Chat namespace translator. + * @returns the secondary disclosure title. + */ +export function processTitle( + summary: ProcessActivitySummary, + closed: boolean, + t: ChatViewSlotProps['t'], +): string { + if (!closed) return t(`message.stepProcess.${summary.running ?? 'thinking'}`) + const labels = summary.counts.slice(0, 3).map(({ kind }) => t(`message.stepProcess.done.${kind}`)) + const first = labels[0] + if (first === undefined) return t('message.stepProcess.done.thinking') + const continuation = (label: string): string => label.charAt(0).toLowerCase() + label.slice(1) + const second = labels[1] + if (second === undefined) return first + if (labels.length === 2) { + const prefix = t('message.stepProcess.sharedPrefix') + const shared = prefix !== '' && first.startsWith(prefix) && second.startsWith(prefix) + return t('message.stepProcess.joinTwo', { first, second: continuation(shared ? second.slice(prefix.length) : second) }) + } + const title = [first, ...labels.slice(1).map(continuation)].join(t('message.stepProcess.comma')) + return summary.counts.length > 3 ? t('message.stepProcess.more', { title }) : title +} diff --git a/packages/client/ui-chat/src/client/chat/use-chat-navigation.ts b/packages/client/ui-chat/src/client/chat/use-chat-navigation.ts index d74de13d6d..84e2b3ec77 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-navigation.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-navigation.ts @@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from 'react' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatScrollPosition, ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnRailItem } from './turn-rail-items.ts' import type { ChatReading, ReadingSample } from './use-chat-reading.ts' import type { ChatViewport } from './use-chat-viewport.ts' @@ -25,8 +25,6 @@ interface TurnJump { /** Owns one replaceable turn jump and the anchor retained while history loads. */ export class ChatNavigation { private jump: TurnJump | null = null - private anchor: ChatScrollPosition | null = null - private committed: Pick private settleFrame: number | null = null constructor( @@ -34,9 +32,7 @@ export class ChatNavigation { private readonly reading: ChatReading, private input: ChatNavigationInput, private readonly onBusyTurn: (turn: number | null) => void, - ) { - this.committed = input - } + ) {} /** * Adopt committed history availability without starting a request. @@ -44,10 +40,9 @@ export class ChatNavigation { */ setInput(input: ChatNavigationInput): void { this.input = input } - /** Cancel navigation and establish the current history window as the baseline. */ + /** Cancel navigation when opening a Chat view. */ reset(): void { this.cancel() - this.committed = this.input } /** Cancel local callbacks; late history completions cannot revive a task. */ @@ -64,7 +59,7 @@ export class ChatNavigation { private clearTask(): void { this.cancelFrame() this.jump = null - this.anchor = null + this.viewport.stopPreserving() } /** @@ -73,15 +68,15 @@ export class ChatNavigation { */ readonly navigateToTurn = (item: TurnRailItem): void => { if (item.anchor.kind === 'loaded') { + this.cancel() const landing = this.viewport.scrollToTurn(item.turn) if (landing === null) return - this.cancel() this.reading.acceptNavigation(landing) - this.anchor = this.input.loadingOlder ? landing.position : null + if (this.input.loadingOlder) this.viewport.beginPreserving(landing.position) return } - this.cancelFrame() - this.anchor = this.viewport.capturePosition() + this.cancel() + this.viewport.beginPreserving() this.reading.pauseFollowing() const jump: TurnJump = { turn: item.turn, @@ -97,7 +92,9 @@ export class ChatNavigation { /** Request one older page while retaining the current semantic position. */ readonly loadEarlier = (): void => { - this.anchor = this.viewport.capturePosition() + this.cancel() + this.viewport.beginPaging() + this.reading.pauseFollowing() this.input.loadOlder() } @@ -107,27 +104,27 @@ export class ChatNavigation { */ readerSampled(sample: ReadingSample): void { if (sample.movedByReader && this.jump?.landing === 'landed') this.jump.landing = 'interrupted' - if (sample.followingTail) this.anchor = null - else if (this.anchor !== null && sample.position !== null) this.anchor = sample.position + if (sample.followingTail || sample.movedByReader) this.viewport.stopPreserving() } /** - * Reconcile a newly committed history head with any retained anchor. - * @returns whether a prepended window consumed the current position-preservation operation. + * Preserve one paging anchor after a commit or a later size change, regardless of head identity. + * @returns whether the retained anchor handled the layout change. */ contentCommitted(): boolean { - const previous = this.committed - this.committed = this.input - if (this.anchor !== null && this.input.firstSeq !== null && previous.firstSeq !== null - && this.input.firstSeq < previous.firstSeq) { - if (this.landJump(false)) return true - const landing = this.viewport.preserve(this.anchor) - this.anchor = this.jump === null ? null : landing?.position ?? null - if (landing !== null) this.reading.preservePosition(landing) - return true + if (!this.viewport.preserving || this.reading.pending) return false + if (this.landJump(false)) return true + const landing = this.viewport.preserve() + if (landing === null) return false + this.reading.preservePosition(landing) + return true + } + + /** Retarget a still-loading page only after inner or outer reader scrolling ends. */ + readerSettled(): void { + if (this.input.loadingOlder && this.jump === null && !this.viewport.preserving && !this.reading.followingTail) { + this.viewport.beginPreserving() } - if (previous.loadingOlder && !this.input.loadingOlder && this.jump === null) this.anchor = null - return false } /** Land, retry, or complete the current jump against the committed window. */ @@ -143,7 +140,7 @@ export class ChatNavigation { const uncovered = this.input.firstSeq === null || this.input.firstSeq > jump.seq if (uncovered && this.input.hasMore && jump.repageHead !== this.input.firstSeq) { jump.repageHead = this.input.firstSeq - this.anchor = this.viewport.capturePosition() + this.viewport.beginPreserving() this.request(jump) return } @@ -164,7 +161,7 @@ export class ChatNavigation { this.reading.acceptNavigation(landing) if (settle) this.cancel() else { - this.anchor = landing.position + this.viewport.beginPreserving(landing.position) jump.landing = 'landed' } return true diff --git a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts index ca5cbd1f37..785cc162a6 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-scroll.ts @@ -63,6 +63,11 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { reading.restore() return } + if (ownInput) { + navigation.cancel() + reading.followTail() + return + } if (navigation.contentCommitted()) { navigation.reconcile() return @@ -71,7 +76,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { || current.firstSeq !== previous.firstSeq || current.lastKey !== previous.lastKey || current.order.length !== previous.order.length || current.running !== previous.running || current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId - if (ownInput || (tipChanged && reading.followingTail)) { + if (tipChanged && reading.followingTail) { navigation.cancel() reading.followTail() } else navigation.reconcile() @@ -80,9 +85,13 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState { useLayoutEffect(() => { const disconnectViewport = viewport.connect({ scroll: reading.onScroll, - scrollEnd: reading.onScrollEnd, + scrollEnd: () => { + reading.onScrollEnd() + navigation.readerSettled() + }, + interact: () => { navigation.cancel() }, resize: () => { - reading.onResize() + if (!navigation.contentCommitted()) reading.onResize() navigation.reconcile() }, }) diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index 2be7eb66a1..a817b2a909 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -27,6 +27,7 @@ interface ViewportEvents { scroll: (event: ViewportScroll) => void scrollEnd: () => void resize: () => void + interact: () => void } interface ViewportElements { @@ -35,6 +36,14 @@ interface ViewportElements { readonly composer: HTMLElement | null } +interface PagingPosition { + readonly row: HTMLElement + readonly position: ChatScrollPosition + readonly group: { readonly body: HTMLElement; readonly content: HTMLElement; readonly top: number } | null +} + +const READING_INTENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'] as const + /** Owns one Chat scrollport's DOM operations, event listeners, and size observer. */ export class ChatViewport { private elements: ViewportElements | null = null @@ -42,6 +51,7 @@ export class ChatViewport { private events: ViewportEvents | null = null private turns: ReturnType = [] private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null } + private paging: PagingPosition | null = null /** * Bind to the containing scrollport and observe content and viewport sizes. @@ -55,7 +65,8 @@ export class ChatViewport { const elements = { list, scroller, composer } this.elements = elements scroller.addEventListener('scroll', this.onScroll, { passive: true }) - scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true }) + scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true, capture: true }) + for (const type of READING_INTENTS) scroller.addEventListener(type, this.onIntent, { passive: true, capture: true }) if (typeof ResizeObserver !== 'undefined') { this.observer = new ResizeObserver(() => { if (this.elements !== elements) return @@ -70,8 +81,10 @@ export class ChatViewport { /** Disconnect DOM resources and clear observations for the detached view. */ detach(): void { + this.stopPreserving() this.elements?.scroller.removeEventListener('scroll', this.onScroll) - this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd) + this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd, true) + for (const type of READING_INTENTS) this.elements?.scroller.removeEventListener(type, this.onIntent, true) this.observer?.disconnect() this.observer = null this.elements = null @@ -151,7 +164,7 @@ export class ChatViewport { } /** - * Capture the reader's position using visible transcript rows. + * Capture visible transcript content, excluding Turn controls that relocate when history expands. * @returns a visible semantic anchor, or null when no anchor can be resolved. */ capturePosition(): ChatScrollPosition | null { @@ -167,13 +180,18 @@ export class ChatViewport { const right = Math.min(viewport.right, content.right) for (const element of document.elementsFromPoint(left + Math.max(0, right - left) / 2, viewport.top + 1)) { const row = element instanceof HTMLElement ? element.closest('[data-chat-anchor-key]') : null - if (row !== null && list.contains(row)) { anchor = row; break } + if (row !== null && row.dataset.chatFlowKind !== 'turn-process' && list.contains(row)) { + // An open group's header stays put while older members enter above its body content. + anchor = row.dataset.chatGroupKey === undefined ? row + : row.querySelector('[data-step-process-content] > [data-chat-anchor-key]:not(:empty):not([hidden]):not([hidden] *)') ?? row + break + } } } if (anchor === null) { const rows = list.querySelectorAll( - ':is([data-chat-flow], [data-chat-flow] > [data-chat-group-key])' - + ' > [data-chat-flow-key]:not([data-chat-group-key]):not(:empty):not([hidden]):not([hidden] *)', + '[data-chat-flow-key]:not([data-chat-group-key]):not([data-chat-flow-kind="turn-process"])' + + ':not(:empty):not([hidden]):not([hidden] *)', ) let low = 0 let high = rows.length @@ -270,14 +288,93 @@ export class ChatViewport { return metrics === null ? null : this.write(position.scrollTop, metrics, null) } + /** Retain the first eligible transcript seat in DOM order; selection reads no geometry. */ + beginPaging(): void { + this.stopPreserving() + const row = this.elements?.list.querySelector( + '[data-chat-paging-anchor]:not(:empty):not([hidden]):not([hidden] *)', + ) + if (row != null) this.retain(row) + } + /** - * Preserve an existing anchor without falling back to an unrelated row. - * @param position - semantic anchor to retain after content changes. - * @returns the compensated landing, or null if the anchor is unavailable. + * Retain one old row and its inner/outer offsets for paging and later content growth. + * @param position - an explicit landing to retain; omitted callers capture the current reading position. */ - preserve(position: ChatScrollPosition): ViewportLanding | null { + beginPreserving(position: ChatScrollPosition | null = this.capturePosition()): void { + this.stopPreserving() + if (position === null) return const row = this.anchor(position.anchorKey) - return row === null ? null : this.align(row, position.anchorTop, null) + if (row === null) return + this.retain(row, position) + } + + private retain(row: HTMLElement, position?: ChatScrollPosition, groupTop?: number): void { + const elements = this.elements + const key = row.dataset.chatAnchorKey + if (elements === null || key === undefined) return + const previous = this.paging?.group + if (previous != null) this.observer?.unobserve(previous.content) + const top = row.getBoundingClientRect().top + const body = row.closest('[data-step-process-body]') + const content = body?.querySelector('[data-step-process-content]') + const group = body === null || content == null ? null + : { body, content, top: groupTop ?? top - body.getBoundingClientRect().top } + this.paging = { + row, group, + position: position ?? { + anchorKey: key, + anchorTop: top - elements.scroller.getBoundingClientRect().top, + scrollTop: elements.scroller.scrollTop, + }, + } + if (group !== null) this.observer?.observe(group.content) + } + + /** Release paging ownership and its content-size observation. */ + stopPreserving(): void { + const group = this.paging?.group + if (group != null) this.observer?.unobserve(group.content) + this.paging = null + } + + /** @returns whether a paging row is retained for subsequent layout changes. */ + get preserving(): boolean { return this.paging !== null } + + /** + * Compensate inner scrolling first, then the outer scrollport, within their actual scroll ranges. + * @returns the actual landing, or null when no visible retained row remains. + */ + preserve(): ViewportLanding | null { + let paging = this.paging + const elements = this.elements + if (paging === null || elements === null) return null + if (!elements.list.contains(paging.row)) { + // Segmentation can remount the same semantic row under another group. + const replacement = this.anchor(paging.position.anchorKey) + if (replacement === null) { + this.stopPreserving() + return null + } + this.retain(replacement, paging.position, paging.group?.top) + paging = this.paging as PagingPosition + } + const { row, group, position } = paging + if (row.closest('[hidden]') !== null || row.matches(':empty')) { + this.stopPreserving() + return null + } + if (group !== null && group.body.contains(row)) { + const top = row.getBoundingClientRect().top - group.body.getBoundingClientRect().top + const floor = Math.max(0, group.body.scrollHeight - group.body.clientHeight) + const target = Math.max(0, Math.min(floor, group.body.scrollTop + top - group.top)) + if (group.body.scrollTop !== target) group.body.scrollTop = target + } + const metrics = this.metrics() + if (metrics === null) return null + const top = row.getBoundingClientRect().top - elements.scroller.getBoundingClientRect().top + const target = metrics.top + top - position.anchorTop + return this.write(target, metrics, null, { key: position.anchorKey, top }) } /** @@ -321,12 +418,23 @@ export class ChatViewport { if (this.elements === null || event.target !== this.elements.scroller) return if (this.observation.landing !== null && this.elements.scroller.scrollTop === this.observation.top) return this.invalidate() + if (this.paging !== null) { + this.events?.resize() + return + } const scroll = this.readScroll() if (scroll !== null) this.events?.scroll(scroll) } private readonly onScrollEnd = (event: Event): void => { - if (event.target === this.elements?.scroller) this.events?.scrollEnd() + if (event.target === this.elements?.scroller + || (event.target instanceof HTMLElement && event.target.hasAttribute('data-step-process-body'))) this.events?.scrollEnd() + } + + private readonly onIntent = (): void => { + if (this.paging === null) return + this.stopPreserving() + this.events?.interact() } } diff --git a/packages/client/ui-chat/src/client/contract/process-groups.ts b/packages/client/ui-chat/src/client/contract/process-groups.ts new file mode 100644 index 0000000000..0c7bc7445a --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/process-groups.ts @@ -0,0 +1,23 @@ +/** Business data for Chat process groups; source Nodes remain in the Chat store. */ +export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code' + | 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools' + +/** Distinct-call category ranking and the current live task detail. */ +export interface ProcessActivitySummary { + readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[] + readonly running: ProcessActivity | undefined + readonly runningDetail: string +} + +/** Presentation facts for one group between independent replies or input. */ +export interface ProcessGroupData { + readonly turn: number + readonly closed: boolean + readonly summary: ProcessActivitySummary +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + chat: ProcessGroupData + } +} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index e28ce1299c..02ada8b317 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -131,6 +131,7 @@ export interface ChatNodeOwnerProps { /** Shared presentation state for one Turn-process answer generation. */ export interface TurnProcessOwnerProps { + readonly hasContent: boolean readonly spec: TurnProcessSpec readonly foldable: boolean readonly open: boolean diff --git a/packages/client/ui-chat/src/client/contract/turn-process.ts b/packages/client/ui-chat/src/client/contract/turn-process.ts index d2ef626cc6..8b7d89f64e 100644 --- a/packages/client/ui-chat/src/client/contract/turn-process.ts +++ b/packages/client/ui-chat/src/client/contract/turn-process.ts @@ -60,3 +60,15 @@ export function sameTurnProcessSpec(left: TurnProcessSpec, right: TurnProcessSpe export function isSubagentDelegationTool(name: string): boolean { return name === 'subagent' || name.startsWith('subagent_') } + +/** + * Keep live, stopped, and failed Turns open. + * @param node - Node carrying the owning Turn. + * @returns whether whole-Turn collapse is unavailable. + */ +export function turnProcessAlwaysOpen(node: ChatNode | undefined): boolean { + const location = node?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return false + const reason = location.turn.end?.data.reason.kind + return location.turn.status === 'open' || reason === 'aborted' || reason === 'error' +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml new file mode 100644 index 0000000000..4c52384108 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/src/client/conversation-nodes/README.md +README.md: 997db5a237f4775bd7642b828e5d4a01de31ab18 +README.zh.md: 7b15b9f526fc7a55f90e017b021487c93c6ac3b1 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md new file mode 100644 index 0000000000..997db5a237 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -0,0 +1,276 @@ +--- +description: "Detailed business rules for Chat process grouping and activity summaries." +--- +# Chat conversation node rules + +English | [中文](README.zh.md) + +## Summary + +A Turn shows an answer and lets the reader inspect the work behind it. Start with the complete example below, then read the folding and display rules to understand what is visible. The later sections define exact group boundaries, every tool-name category, counting, and live-detail selection. + +## Table of Contents + +- [How one Turn becomes a transcript](#reading-model) +- [Whole-Turn folding](#whole-turn-folding) +- [Display modes](#display-modes) +- [Process grouping](#process-grouping) +- [Activity summaries and tool rules](#activity-summaries) + +----- + + +## How one Turn becomes a transcript + +Grouping, counts, display modes, and whole-Turn folding answer different questions. Grouping decides which process rows belong together. Counts summarize work inside an existing group. Display modes decide how much of each group to show. Whole-Turn folding can hide the entire process across several groups and intermediate replies. + +### A complete example + +Suppose the assistant thinks, reads a file, runs a command, posts a progress reply, then thinks and runs code before giving its final answer. After completion, that Turn contains: + +- User input. +- Whole-Turn control. +- Process content controlled by the whole-Turn control: + - Group G1: reasoning, `read`, `bash`. + - Intermediate reply: outside both groups, but still part of the Turn's process. + - Group G2: reasoning, `run_code`. +- Final response, outside the process fold. +- Completed-turn footer. + +Every reply separates secondary groups; only the final answer is protected from whole-Turn folding. An intermediate reply can be an independent row and still disappear when the Turn is folded. Reasoning attached to the final answer remains process content. + +| Question | Deciding rule | Result in this example | +|---|---|---| +| Which rows belong together? | Group boundaries | The reply separates G1 and G2; changing from `read` to `bash` does not. | +| What did a group do? | Category counts | G1 has `read=1, commands=1`; G2 has `code=1`. Reasoning adds no tool count. | +| What does its title say? | Group state and summary | Running groups use their latest running activity; closed groups use their highest-count categories. Counts never decide folding. | +| How much detail is visible? | Display mode and manual group opening | Compact/Detailed start with group headers; Expanded shows bodies without headers. | +| Is the process visible at all? | Whole-Turn opening | Closing the Turn hides G1, the intermediate reply, and G2 together. | + +### What the reader sees + +Assume the Turn above completed normally, with no inner disclosure manually opened: + +| Viewing state | Visible content | +|---|---| +| Whole Turn collapsed, any mode | Input, whole-Turn control, final response, and footer. No process headers, bodies, or intermediate reply. | +| Whole Turn open, Compact or Detailed | G1/G2 headers, the intermediate reply, and final response. Group bodies start collapsed. | +| Whole Turn and G1 open, Detailed | G1's reasoning and tool rows with the settled reasoning preview; G2 remains a header. Full reasoning/tool bodies are still manual. | +| Whole Turn open, Expanded | G1/G2 bodies, intermediate reply, and final response. No group headers or group-level height caps; individual full bodies remain manual. | + +Visibility applies from outside inward: whole Turn → secondary group → individual reasoning/tool disclosure. Opening an inner layer cannot bypass a closed outer layer. Category changes never open or close a layer. Mode changes preserve membership and manual opening choices. + +----- + + +## Whole-Turn folding + +Whole-Turn folding controls the loaded process range, independently of secondary groups. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. + +| Turn condition | Current behavior | +|---|---| +| Not closed | Keep process content open; whole-Turn collapse is unavailable. Secondary groups still follow the mode table. | +| Stopped or failed | Keep process content open; whole-Turn collapse is unavailable. | +| Other closed Turn with loaded process content | Initially collapse the process; allow manual opening and closing. Neither the Turn start nor the rest of Session history must already be loaded. | +| Closed Turn with no final answer | Collapse all process rows when otherwise eligible. | +| End is loaded but start is absent | Apply the closed-Turn rules above to loaded content. Show no elapsed duration; do not use the first loaded tool/message time as the start. | +| Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. | +| A Turn control exists, but there is no process content | Retain its title, without a collapse action. | + +The final answer is the latest Step's settled Assistant reply, provided it has visible reply content and no tool-call block. Its response remains outside whole-Turn folding; its reasoning remains process content. User and steering inputs, trigger notices, terminal errors, max-token notices, and the completed-turn footer remain independent. Secondary grouping treats model retries as separators, but whole-Turn folding still includes retry rows. + +Loading an older page does not itself change the reader's opening choice. Newly loaded process content follows the same Turn state while the final answer is unchanged. When the real start arrives, the duration becomes available; loading all history is not an additional folding condition. When new content only extends an existing group at its beginning, that group and its old message rows retain their identities and opening choices. Replies, steering, and other real boundaries in the new page still separate groups; not every new row joins the old group. + +Clicking Load older anchors the first visible content item below that button in transcript order, regardless of its position in the viewport. A collapsed group anchors its header; an expanded group skips its header and anchors its first visible member. The whole-Turn process control is excluded because paging can move it ahead of newly loaded work and steering. When a whole Turn is collapsed, the anchor is its first remaining visible message, such as steering or the final answer. Ordinary messages and steering anchor themselves; hidden and empty rows are skipped. Loading more content inside a collapsed group keeps its header stationary; adding earlier groups or rows preserves that same group header while they appear above it. + +Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until reader input or explicit navigation releases it. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor. + +A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator. + +Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content. + +----- + + +## Display modes + +Settings → General → Work details stores `ui-chat.transcriptView` as `compact` (default), `detailed`, or `expanded`. A saved legacy `normal` value reads as `detailed` without being written back. + +| Behavior | Compact | Detailed | Expanded | +|---|---|---|---| +| Process-group header | Category summary | Summary and live task detail | Hidden | +| Process-group body | Initially collapsed | Initially collapsed | Visible, without group-level height cap | +| Settled reasoning preview | Hidden | First line | First line | +| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | +| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | + +A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Detailed appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title. + +### Group-title rules + +The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category. + +| Category | Running label | Closed label | +|---|---|---| +| No live category / no counted categories | Analyzing the request | Analysis completed | +| `read` | Reading files | Read files | +| `search` | Searching code | Searched code | +| `edit` | Editing files | Edited files | +| `commands` | Running commands | Ran commands | +| `code` | Running code | Ran code | +| `webSearch` | Searching the web | Searched the web | +| `webFetch` | Visiting web pages | Visited web pages | +| `subagents` | Coordinating subagents | Coordinated subagents | +| `plan` | Updating the plan | Updated the plan | +| `questions` | Waiting for your action | Asked questions | +| `tools` | Calling tools | Called tools | + +| Group state | Title composition | +|---|---| +| Not closed, with a running tool | Use the live category, not the highest-count category. Detailed appends nonempty detail with ` · `; Compact omits detail. | +| Not closed, without a running tool | Use the analysis label even when completed-tool counts are nonzero. Detailed may append running reasoning detail. | +| Closed, zero categories | Use the completed-analysis label. | +| Closed, one category | Use its closed label without a count. | +| Closed, two categories | Join ranked labels with “and”. Chinese removes the second leading `已` only when both labels start with it. | +| Closed, three categories | Join all three ranked labels with commas. | +| Closed, more than three categories | Show the first three labels followed by “etc.” (`等` in Chinese). | + +English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Expanded hides the entire header. + +Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing. + +Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography. + +Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn. + +----- + + +## Process grouping + +[process-groups.ts](process-groups.ts) groups visible Chat content; [process-activity.ts](process-activity.ts) summarizes the activity inside each group. Both follow the rules below. + +A group collects adjacent process content within one Turn. Step-number changes alone do not split it. Compact, Detailed, and Expanded use the same grouping result. A group's `closed` flag means its content segment has ended, not that its UI disclosure is collapsed. + +| Input | Membership and segmentation | +|---|---| +| Non-blank Assistant reasoning | Append one `reasoning` reference for that Assistant to the current group, creating a group if necessary. | +| Assistant reply | End the preceding group and emit an independent `response` reference. If the same Node has reasoning, append that reasoning before ending the group. | +| `user`, `steering`, `turn-trigger`, `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail` | End the preceding group and retain the Node as an independent root. | +| `turn-process` | Retain the control as an independent root without ending the current group. | +| Other visible Nodes owned by a Turn, including tools | Append the whole Node to the current group, creating a group if necessary. | +| Node from another Turn or without a Turn | Break the same-Turn sequence. A Node without a Turn remains independent, including an unsplit Assistant. | +| Hidden Nodes, system prompts, ordinary Context injection, and `permission` commands | Excluded from the grouping input; they neither join nor split a group. | + +A reply contains non-blank text, an image, or another visible block; reasoning and tool-call protocol blocks do not count as a reply. An Assistant with neither non-blank reasoning nor reply content contributes no reference. Group creation requires a member, so metadata-only Turns create no empty group. + +### Examples + +`A` denotes an Assistant Node, `T` a tool Node, and `G[...]` a process group. Arrows show input or root-reference order; examples share one Turn unless stated otherwise. + +| Input sequence | Grouped roots | +|---|---| +| `A1(thought, reply) → T1` in one Step | `G[A1.reasoning] → A1.response → G[T1]` | +| `T1(step=1) → A2(thought, step=2) → T2` | `G[T1, A2.reasoning, T2]` | +| `T1 → steering → T2` | `G[T1] → steering → G[T2]` | +| `T1(turn=1) → U(no turn) → T2(turn=1)` | `G[T1] → U → G[T2]` | +| `A1(reply)` | `A1.response`, without a process group | +| `read → bash → subagent_codex` without an intervening reply or input | One group; changing tool category does not split it. | +| `run_code` with nested `read` and `bash` calls | One group member for the `run_code` tool Node; its nested calls do not create separate roots. | +| A tool receives a failed result | Update that member without splitting its group; a separate `turn-error` Node does split the sequence. | + +Replies and independent separators close preceding groups immediately, even while the Turn is running. The final group also closes when another Turn or unscoped Node follows it, or when its Turn has status `closed`. Any other or unavailable Turn status alone does not close that trailing group. Regrouping after a separator disappears may reopen it. + +----- + + +## Activity summaries + +[process-activity.ts](process-activity.ts) derives one group's category counts and live detail from its member Nodes. It does not decide membership or presentation text. + +### Category counts + +Counts describe calls in this group, not successful operations, files changed, child Agents, or elapsed work. The following rules apply before category ranking. + +| Call evidence in this group | Counting rule | +|---|---| +| Running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. | +| Repeated `callId` | Count only the first occurrence, including when it appears both as a root and as a nested call. Later occurrences and their subtrees are skipped. | +| Parent and child with different `callId` values | Count both, in their own categories; visit the parent before its children and siblings in recorded order. | +| Repeated calls to the same tool with different `callId` values | Count each call, even with identical arguments or the same terminal/session target. | +| Settled root result whose original call is missing | Do not infer its category from result text or metadata. Count no root call; its recorded subcalls still participate. | +| Assistant reasoning, Assistant tool-call protocol blocks, or another Node kind | Add no tool count. The separately projected tool Node owns that call's count. | + +Deduplication is group-local. Counts sort descending; ties retain the category's first occurrence in member order and parent-before-child traversal. For `read → bash → read`, the ranking is `read=2, commands=1`; for `bash → read`, it is `commands=1, read=1`. No category has a fixed priority when counts tie. + +### Tool-name classification + +Classification uses the recorded tool name exactly: no case folding, namespace stripping, provider lookup, or inference from arguments. Names match the first applicable row below. All nonzero categories remain in the summary. + +| Category | Tool-name match | +|---|---| +| `read` | `read`, `read_image`, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` | +| `search` | `grep`, `glob`, or suffix `_inspect` | +| `edit` | `write`, `edit`, `apply_patch` | +| `commands` | `bash`, `pwsh`, `exec_command`, `write_stdin`, or prefix `terminal_` | +| `code` | `run_code` | +| `webSearch` | `web_search` | +| `webFetch` | `web_fetch` | +| `subagents` | `subagent` or prefix `subagent_` | +| `plan` | `todo_write`, `create_goal`, `update_goal`, `get_goal` | +| `questions` | `ask_user_question`, `request_user_input` | +| `tools` | Every other name | + +### Subagents, background jobs, and nested calls + +A subagent tool follows ordinary group boundaries: it does not create a group merely because it delegates work. Its category describes the parent Session's tool call, not the number or lifecycle of child Sessions. + +| Case | Category and activity behavior | +|---|---| +| `subagent`, `subagent_codex`, `subagent_claude_code`, or another `subagent_*` name | Count one `subagents` call per distinct `callId`, unless an earlier name rule matches. Provider and model choices do not change the category. | +| A configured name such as `subagent_inspect` | Classify as `search`: suffix `_inspect` is checked before prefix `subagent_`. The same precedence makes `terminal_inspect` a `search` call rather than `commands`. | +| Subagent controls `send_message`, `list_agents`, and `list_subagent_models`; job controls `job_output` and `job_kill` | Classify as `tools`, not `subagents`. Their business purpose does not override the name table. | +| A delegation tool renamed to a name outside the table, such as `research_agent` | Classify as `tools`, even if it invokes the same subagent provider. | +| Background subagent startup has returned its tool result, while the child is still running | Keep its category count, but remove that call from running-activity candidates. The child does not keep the parent's group in the subagent activity state. | +| Tool calls recorded only in a child Session | Do not include them in the parent group's counts or live detail. Only calls actually attached to a member's `subCalls` participate recursively. | + +A `run_code` root counts once as `code`; its recorded PTC subcalls each count in their own categories. The wrapper is not replaced by leaf-call counts, and children are not all classified as `code`. With distinct call ids, `run_code → [read, bash, subagent_codex]` produces `code=1, read=1, commands=1, subagents=1`, in that tie order. A subcall can supply the live category while the outer `run_code` remains running. + +Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.read`, or `Read` falls into `tools`, while `browser_inspect` falls into `search`. A `terminal_*` name follows the command rule even when its operation only inspects or closes a terminal, unless the earlier `_inspect` rule matches. + +### Live activity and detail + +Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules. + +Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category. + +| Members' current state | Live category and detail source | +|---|---| +| `bash(time=10)` and `read(time=20)` both running | `read`, using the `read` arguments, regardless of which tool most recently emitted output. | +| The newer `read` settles while the older `bash` remains running | Return to `commands`, using the `bash` arguments. A failed `read` result has the same selection effect. | +| Running parent and child share the same start time | The child wins because it is visited later; later siblings win equal-time ties. | +| All tools settled; the group is not closed; a running Assistant has reasoning | No live tool category; use the latest nonempty reasoning paragraph as detail. | +| No running tool or usable running reasoning; the group is not closed | No live tool category and empty detail, even when category counts are nonzero. | +| A reply or another group boundary closes the group while a tool still lacks a result | Published live category and detail are cleared. Group closure takes precedence over the unfinished call. | + +Tool detail uses the first nonempty supported argument field in this priority order: `title`, `description`, `objective`, `task`, `task_name`, `name`, `question`, `questions`, `prompt`, `message`, `command`, `cmd`, `queries`, `query`, `pattern`, `url`, `uri`, `file_path`, `path`, `target`, `action`, `status`. Strings and arrays consisting entirely of strings are supported; arrays join with `, `. The `questions` field instead selects the first nonempty `question` in its object array. Invalid, partial, or free-form JSON and arguments without usable detail fall back to the tool name. + +The priority list is shared by every tool, not specialized per category. For example, `description` can override `command` on a shell call, and `title` can override a file path. The first field whose supported value remains nonempty after whitespace normalization wins; unsupported values are skipped. + +| Tool and argument example | Resulting detail | +|---|---| +| `subagent` with `{"description":"Review parser","prompt":"Long instructions"}` | `Review parser`; the prompt is not appended. | +| `send_message` with `{"subagent_id":"child-1","message":"Check tests"}` | `Check tests`; its category is still `tools`. | +| `exec_command` with `{"cmd":"pnpm test","description":"Run focused tests"}` | `Run focused tests`; without a usable description, `pnpm test`. | +| `read` with `{"file_path":"src/app.ts","path":"fallback.ts"}` | `src/app.ts`; `file_path` precedes `path`. | +| `grep` with `{"pattern":"TODO","path":"src"}` | `TODO`; the searched path is not appended. | +| `web_search` with `{"queries":["React hooks","external store"]}` | `React hooks, external store`. | +| `web_search` with only `{"queries":[{"query":"React hooks"}]}` | `web_search`; object-array queries are not extracted recursively. | +| `web_fetch` with `{"url":"https://example.com"}`; `read_mcp_resource` with `{"uri":"resource://guide"}` | The URL or URI itself. | +| `request_user_input` with `{"questions":[{"header":"Scope","question":"Which package?"},{"question":"Which mode?"}]}` | `Which package?`; ignore headers and later questions once a nonempty question is found. | +| `run_code` with only `{"code":"print(1)"}` | `run_code`; `code` is not in the detail-field list. | +| `apply_patch` with raw patch text rather than a JSON object | `apply_patch`; no patch-path extraction. | +| Any tool with `{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`; whitespace-only strings and numbers are skipped. | +| Partial JSON, a JSON scalar, a mixed-type array, or only unsupported fields | Use the tool name if no other usable field exists. No partial-argument guessing. | + +All live detail collapses whitespace, trims its ends, and is limited to 160 grapheme clusters, including a final `…` when truncated. A closed group's published summary clears its live category and detail while retaining category counts. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md new file mode 100644 index 0000000000..7b15b9f526 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -0,0 +1,276 @@ +--- +description: "Chat 过程分组与活动摘要的明细业务规则。" +--- +# Chat 节点业务规则 + +[English](README.md) | 中文 + +## 概述 + +一轮对话既要展示答案,也要让用户按需查看产生答案的过程。先通过下面的完整例子理解画面,再看整轮折叠和展示模式决定什么可见;后面的明细逐项定义分组边界、所有工具名称类别、计数及实时详情选择。 + +## 目录 + +- [先理解一轮对话怎样显示](#reading-model) +- [整轮折叠](#whole-turn-folding) +- [展示模式](#display-modes) +- [过程分组](#process-grouping) +- [活动摘要与工具明细](#activity-summaries) + +----- + + +## 先理解一轮对话怎样显示 + +分组、类别计数、展示模式和整轮折叠分别回答不同问题:分组决定哪些过程行放在一起;计数概括一个已有组里做了什么;展示模式决定组内显示多少;整轮折叠则可以一次隐藏跨越多个组和阶段回复的全部过程。 + +### 一个完整例子 + +假设助手先思考、读文件、运行命令,给出一段进展回复,然后继续思考、执行代码,最后给出答案。该轮完成后,内容在业务上分成: + +- 用户输入。 +- 整轮控件。 +- 由整轮控件控制的过程范围: + - 过程组 G1:思考、`read`、`bash`。 + - 阶段回复:不属于 G1 或 G2,但仍属于整轮过程。 + - 过程组 G2:思考、`run_code`。 +- 最终答案正文,不随过程收起。 +- 已完成轮次页脚。 + +每次回复都会截断二级组,但只有最终答案正文不参与整轮折叠。因此,阶段回复虽然是一行独立内容,整轮收起时仍会消失。最终答案附带的思考仍属于过程内容。 + +| 问题 | 由什么决定 | 例子中的结果 | +|---|---|---| +| 哪些行放在一起? | 分组边界 | 阶段回复分开 G1 与 G2;从 `read` 换成 `bash` 不会拆组。 | +| 一组做了什么? | 类别计数 | G1 为 `read=1, commands=1`,G2 为 `code=1`;思考不增加工具次数。 | +| 组标题写什么? | 组状态与摘要 | 未结束组显示最近运行活动;已结束组显示次数最多的类别。次数不决定折叠。 | +| 组内显示多少细节? | 展示模式与手动开合 | Compact/Detailed 初始只显示组头;Expanded 显示组正文、不显示组头。 | +| 过程整体是否可见? | 整轮开合 | 整轮收起时,G1、阶段回复和 G2 一起隐藏。 | + +### 用户实际看到什么 + +以下假设上面的轮次正常结束,用户尚未手动打开内部控件: + +| 查看状态 | 可见内容 | +|---|---| +| 整轮收起,任意模式 | 输入、整轮控件、最终答案和页脚;过程组头、正文及阶段回复都不显示。 | +| 整轮展开,Compact 或 Detailed | G1/G2 组头、阶段回复及最终答案;组正文初始收起。 | +| 整轮和 G1 展开,Detailed | G1 的思考与工具行,思考带已结算预览;G2 仍是组头。单个完整正文仍需手动打开。 | +| 整轮展开,Expanded | G1/G2 正文、阶段回复及最终答案;没有组头与组级限高,单个完整正文仍需手动打开。 | + +显隐从外向内生效:整轮 → 二级组 → 单个思考或工具。内层展开不能越过外层收起。类别变化不会开合任何一层;切换模式保留分组和用户的手动展开选择。 + +----- + + +## 整轮折叠 + +整轮折叠控制已加载的过程范围,独立于二级分组。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 + +| 轮次条件 | 当前行为 | +|---|---| +| 尚未结束 | 过程内容保持展开,不能收起整轮;二级组仍按模式表展示。 | +| 已停止或失败 | 过程内容保持展开,不能收起整轮。 | +| 其他已结束轮次,有已加载的过程内容 | 过程默认收起,允许手动开合;不要求已加载到轮次起点,也不要求整个 Session 历史都已加载。 | +| 已结束但没有最终答案 | 满足其他折叠条件时收起全部过程行。 | +| 已加载结束记录,但缺少起点 | 对已加载内容应用上述已结束轮次规则;不显示耗时,不用最早加载的工具或消息时间冒充起点。 | +| 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 | +| 存在整轮控件,但没有过程内容 | 保留标题,不提供折叠操作。 | + +最终答案取最后一个 Step 中已结算的 Assistant 回复,要求有可见回复内容且没有工具调用块。最终答案的回复正文不参与整轮折叠,推理仍属于过程。用户与 steering 输入、触发通知、终止错误、最大 token 提示及已完成轮次页脚保持独立。二级分组把模型重试视为分隔节点,但整轮折叠仍包含重试行。 + +加载更早的一页本身不改变用户的开合选择。最终答案未变化时,新加载的过程内容沿用该轮的开合状态;真正的起点到达后才显示时长,是否已加载完全部历史不是额外折叠条件。若新内容只是接在旧组前面,旧组和旧消息行保留身份及已有展开状态。新页出现回复、steering 等真实边界时仍按规则分组,不把所有新增内容强并进旧组。 + +点击加载更早时,按正文顺序锚定按钮下的第一个可见内容项,不根据它在视口中的位置选择。组收起时选组 header;展开时跳过组 header,选内部第一个可见成员。整轮过程控件不作为锚点,因为分页可能将它移到新加载的过程及 steering 前面。整轮收起时,锚定第一个仍可见的消息,例如 steering 或最终答案。普通消息与 steering 选择自身,跳过隐藏和空行。新增内容全进入收起的组时,组 header 保持原位;前面出现更早的组或节点时,仍保持该组 header 的位置,让新增内容出现在它上方。 + +分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到读者输入或显式导航释放它。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。 + +运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。 + +自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。 + +----- + + +## 展示模式 + +设置 → 通用设置 → 工作过程展示将 `ui-chat.transcriptView` 保存为 `compact`(默认)、`detailed` 或 `expanded`。已保存的旧值 `normal` 读取为 `detailed`,不自动回写。 + +| 行为 | 简洁 | 详细 | 完全展开 | +|---|---|---|---| +| 过程组头 | 类别摘要 | 摘要与实时任务详情 | 隐藏 | +| 过程组正文 | 初始收起 | 初始收起 | 显示,不设组级限高 | +| 已结算推理预览 | 隐藏 | 首行 | 首行 | +| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | +| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | + +已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;详细模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。 + +### 组标题规则 + +以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 + +| 类别 | 未结束组的活动文案 | 已结束组的摘要文案 | +|---|---|---| +| 无实时类别 / 无计数类别 | 正在分析请求 | 已完成分析 | +| `read` | 正在读取文件 | 已读取文件 | +| `search` | 正在搜索代码 | 已搜索代码 | +| `edit` | 正在编辑文件 | 修改了文件 | +| `commands` | 正在运行命令 | 执行了命令 | +| `code` | 正在运行代码 | 运行了代码 | +| `webSearch` | 正在搜索网页 | 已搜索网页 | +| `webFetch` | 正在访问网页 | 已访问网页 | +| `subagents` | 正在协调子任务 | 已协调子任务 | +| `plan` | 正在更新计划 | 更新了计划 | +| `questions` | 等待你的操作 | 向用户提出了问题 | +| `tools` | 正在调用工具 | 已调用工具 | + +| 组状态 | 标题组合规则 | +|---|---| +| 未结束,有运行工具 | 取实时类别,不取计数最多的类别。Detailed 用 ` · ` 追加非空详情,Compact 不追加。 | +| 未结束,没有运行工具 | 即使已完成工具次数非零,也使用分析文案;Detailed 可以追加运行中推理详情。 | +| 已结束,零个类别 | 使用「已完成分析」。 | +| 已结束,一个类别 | 使用该类别的已结束文案,不带次数。 | +| 已结束,两个类别 | 按排名用「并」连接;仅当两项都以 `已` 开头时省略第二项的 `已`,例如 `已读取文件并搜索代码`。 | +| 已结束,三个类别 | 按排名用中文逗号 `,` 连接三项文案。 | +| 已结束,超过三个类别 | 只显示前三项文案,末尾加「等」。 | + +英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。Expanded 隐藏整个组头。 + +组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式取消组级限高,使用 16px 行间距。 + +单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 + +切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。 + +----- + + +## 过程分组 + +[process-groups.ts](process-groups.ts) 划分可见 Chat 内容,[process-activity.ts](process-activity.ts) 汇总每组活动,两者共同遵循下列业务规则。 + +一组收集同一轮次内相邻的过程内容,仅 Step 编号变化不会拆组。Compact、Detailed 和 Expanded 使用同一分组结果。组的 `closed` 表示这段内容已经结束,不表示 UI 开合控件处于收起状态。 + +| 输入 | 归属与切分 | +|---|---| +| 非空白 Assistant 推理 | 将该 Assistant 的一个 `reasoning` 引用加入当前组,必要时创建组。 | +| Assistant 回复 | 结束前面的组,输出独立的 `response` 引用。同一 Node 含有推理时,先将推理加入组,再结束该组。 | +| `user`、`steering`、`turn-trigger`、`model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail` | 结束前面的组,Node 作为独立根引用保留。 | +| `turn-process` | 控件作为独立根引用保留,不结束当前组。 | +| 轮次内其他可见 Node,包括工具 | 将整个 Node 加入当前组,必要时创建组。 | +| 另一轮次或没有轮次归属的 Node | 截断同轮次序列。无轮次 Node 保持独立,Assistant 也不拆分。 | +| 隐藏 Node、系统提示词、普通 Context 注入及 `permission` 命令 | 不进入分组输入,既不入组,也不截断组。 | + +回复须包含非空白文本、图片或其他可见块;推理和工具调用协议块不算回复。既无非空白推理、也无回复内容的 Assistant 不产生引用。只有存在成员才创建组,因此仅含元数据的轮次不会产生空组。 + +### 示例 + +`A` 表示 Assistant Node,`T` 表示工具 Node,`G[...]` 表示过程组。箭头表示输入或根引用顺序;未注明时,示例均属于同一轮次。 + +| 输入序列 | 分组后的根引用 | +|---|---| +| 同一 Step 的 `A1(thought, reply) → T1` | `G[A1.reasoning] → A1.response → G[T1]` | +| `T1(step=1) → A2(thought, step=2) → T2` | `G[T1, A2.reasoning, T2]` | +| `T1 → steering → T2` | `G[T1] → steering → G[T2]` | +| `T1(turn=1) → U(no turn) → T2(turn=1)` | `G[T1] → U → G[T2]` | +| `A1(reply)` | `A1.response`,不产生过程组 | +| `read → bash → subagent_codex`,中间没有回复或输入 | 同一组;工具类别变化不会拆组。 | +| `run_code` 内含 `read`、`bash` 子调用 | `run_code` 工具 Node 是一个组成员,其嵌套调用不产生独立根引用。 | +| 工具收到失败结果 | 更新该成员,不因此拆组;另外产生的 `turn-error` Node 才会截断序列。 | + +即使轮次仍在运行,回复和独立分隔节点也会立即结束前面的组。尾组在后面出现另一轮次或无轮次 Node 时结束,所属轮次状态为 `closed` 时也结束。其他轮次状态或无法获知轮次状态,本身不会结束尾组。分隔节点消失后重新分组,原组可以再次变为未结束。 + +----- + + +## 活动摘要 + +[process-activity.ts](process-activity.ts) 从一组的成员 Node 计算类别次数和实时详情,不决定成员归属或展示文案。 + +### 类别计数 + +这里统计本组的工具调用,不是成功操作数、修改文件数、子 Agent 数量或工作时长。先按下表确定计数,再按类别排序。 + +| 本组中的调用记录 | 计数规则 | +|---|---| +| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | +| 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 | +| 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 | +| 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 | +| 已结算根结果缺少原调用记录 | 不从结果文字或 metadata 猜测类别;根调用不计数,已记录的子调用仍参与统计。 | +| Assistant 推理、Assistant 内的工具调用协议块,或其他 Node 类型 | 不增加工具次数;调用由独立投影出的工具 Node 负责计数。 | + +去重范围是单个组。类别按次数降序排列,同数时保留该类别在成员顺序及父先子后遍历中的首次出现顺序。`read → bash → read` 排为 `read=2, commands=1`;`bash → read` 排为 `commands=1, read=1`。次数相同时没有固定的类别优先级。 + +### 工具名分类 + +分类只使用记录的工具名:不转换大小写、不剥离命名空间、不查 provider,也不根据参数推断。名称按下表从上到下首次命中的规则分类,摘要保留全部非零类别。 + +| 类别 | 工具名匹配 | +|---|---| +| `read` | `read`、`read_image`、`list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` | +| `search` | `grep`、`glob`,或以 `_inspect` 结尾 | +| `edit` | `write`、`edit`、`apply_patch` | +| `commands` | `bash`、`pwsh`、`exec_command`、`write_stdin`,或以 `terminal_` 开头 | +| `code` | `run_code` | +| `webSearch` | `web_search` | +| `webFetch` | `web_fetch` | +| `subagents` | `subagent`,或以 `subagent_` 开头 | +| `plan` | `todo_write`、`create_goal`、`update_goal`、`get_goal` | +| `questions` | `ask_user_question`、`request_user_input` | +| `tools` | 其他所有名称 | + +### Subagent、后台任务与嵌套调用 + +Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成组。它的类别描述父 Session 中的工具调用,不表示子 Session 的数量或生命周期。 + +| 情况 | 类别与活动规则 | +|---|---| +| `subagent`、`subagent_codex`、`subagent_claude_code` 或其他 `subagent_*` 名称 | 每个不同 `callId` 计一次 `subagents`,但先命中更靠前的名称规则时除外;provider 和 model 选择不改变类别。 | +| 配置为 `subagent_inspect` 这样的名称 | 归 `search`:`_inspect` 后缀先于 `subagent_` 前缀匹配;同理,`terminal_inspect` 归 `search` 而不是 `commands`。 | +| 子代理控制工具 `send_message`、`list_agents`、`list_subagent_models`,以及任务控制工具 `job_output`、`job_kill` | 归 `tools`,不归 `subagents`;业务用途不覆盖名称表。 | +| 委派工具被改为表外名称,例如 `research_agent` | 即使调用相同的 subagent provider,也归 `tools`。 | +| 后台 subagent 的启动调用已返回工具结果,但子代理仍在运行 | 保留类别次数,但该调用不再参与运行中活动选择;子代理的执行不会维持父组的 subagent 活动状态。 | +| 工具调用只记录在子 Session 中 | 不纳入父组计数或实时详情;只有实际附在本组成员 `subCalls` 中的调用才递归参与。 | + +`run_code` 根调用自身计一次 `code`,已记录的每个 PTC 子调用另按自己的类别计数。不会用叶子调用替代包装调用,也不会把子调用统一归为 `code`。各调用 id 不同时,`run_code → [read, bash, subagent_codex]` 得到 `code=1, read=1, commands=1, subagents=1`,同次数时按此顺序排列。外层 `run_code` 仍在运行时,组的实时类别可以由子调用决定。 + +精确名称规则也意味着:记录为 `functions.read`、`mcp.read` 或 `Read` 的名称归 `tools`,`browser_inspect` 归 `search`。`terminal_*` 即使只检查或关闭终端,也按命令规则分类,除非先命中 `_inspect` 规则。 + +### 实时活动与详情 + +运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。 + +实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。 + +| 成员当前状态 | 实时类别与详情来源 | +|---|---| +| `bash(time=10)` 与 `read(time=20)` 都运行中 | 取 `read` 及其参数,与哪个工具最近输出无关。 | +| 较新的 `read` 已结算,较早的 `bash` 仍在运行 | 回到 `commands`,使用 `bash` 参数;`read` 失败后结算也一样。 | +| 运行中的父子调用开始时间相同 | 子调用因后遍历而胜出;同级同时间则后遍历者胜出。 | +| 工具全部结算,组尚未结束,运行中的 Assistant 有推理 | 实时工具类别为空,详情取最近的非空推理段落。 | +| 没有运行工具或可用的运行中推理,组尚未结束 | 实时工具类别为空、详情为空,即使类别次数非零。 | +| 工具尚无结果,但回复或其他分组边界已结束该组 | 发布的实时类别与详情清空;组结束优先于调用未完成。 | + +工具详情按以下优先级取第一个非空且受支持的参数字段:`title`、`description`、`objective`、`task`、`task_name`、`name`、`question`、`questions`、`prompt`、`message`、`command`、`cmd`、`queries`、`query`、`pattern`、`url`、`uri`、`file_path`、`path`、`target`、`action`、`status`。支持字符串及全部元素均为字符串的数组,数组使用 `, ` 连接。`questions` 字段则取对象数组中首个非空的 `question`。无效、不完整或自由文本形式的 JSON,以及没有可用详情的参数,均回退到工具名。 + +这份优先级对所有工具相同,不按类别定制。例如 shell 调用的 `description` 可以覆盖 `command`,`title` 可以覆盖文件路径。按顺序选择受支持且合并空白后仍非空的首个字段,不支持的值会跳过。 + +| 工具与参数示例 | 最终详情 | +|---|---| +| `subagent`,`{"description":"Review parser","prompt":"Long instructions"}` | `Review parser`,不追加 prompt。 | +| `send_message`,`{"subagent_id":"child-1","message":"Check tests"}` | `Check tests`,类别仍是 `tools`。 | +| `exec_command`,`{"cmd":"pnpm test","description":"Run focused tests"}` | `Run focused tests`;没有可用 description 时才是 `pnpm test`。 | +| `read`,`{"file_path":"src/app.ts","path":"fallback.ts"}` | `src/app.ts`,`file_path` 先于 `path`。 | +| `grep`,`{"pattern":"TODO","path":"src"}` | `TODO`,不追加搜索路径。 | +| `web_search`,`{"queries":["React hooks","external store"]}` | `React hooks, external store`。 | +| `web_search`,仅有 `{"queries":[{"query":"React hooks"}]}` | `web_search`,不递归提取对象数组中的查询。 | +| `web_fetch` 的 `{"url":"https://example.com"}`;`read_mcp_resource` 的 `{"uri":"resource://guide"}` | 直接使用 URL 或 URI。 | +| `request_user_input`,`{"questions":[{"header":"Scope","question":"Which package?"},{"question":"Which mode?"}]}` | `Which package?`;找到首个非空问题后,不取 header 或后续问题。 | +| `run_code`,仅有 `{"code":"print(1)"}` | `run_code`,`code` 不在详情字段列表中。 | +| `apply_patch`,参数为原始补丁文本而非 JSON 对象 | `apply_patch`,不从补丁中提取路径。 | +| 任意工具,`{"description":" ","command":42,"path":"src/app.ts"}` | `src/app.ts`,跳过纯空白字符串和数值。 | +| 不完整 JSON、JSON 标量、混合类型数组,或仅有不支持的字段 | 没有其他可用字段时使用工具名,不猜测不完整参数。 | + +所有实时详情都合并空白、去除首尾空白,并限制为 160 个字素簇;截断时末尾的 `…` 计入上限。已结束组发布的摘要清空实时类别和详情,保留类别次数。 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 85b565a178..9ef7f32145 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 @@ -362,7 +362,7 @@ function turnProcessPresentations( const location = node.location if (location.kind !== 'turn' && location.kind !== 'step') continue const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {} - if ((node.kind === 'user' || node.kind === 'steering') + if ((node.kind === 'user' || (node.kind === 'steering' && current.control !== undefined)) && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) { presentations.set(location.turn.turn, { ...current, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts new file mode 100644 index 0000000000..d6766b1954 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts @@ -0,0 +1,122 @@ +/** Tool-category and live-detail interpretation owned by Chat grouping. */ +import type { ProcessActivity, ProcessActivitySummary } from '../contract/process-groups.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' +import { isRunningTool } from '../contract/chat-nodes.ts' +import type { ToolCallBlock } from '../contract/snapshot.ts' + +function activity(name: string): ProcessActivity { + if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read' + if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search' + if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit' + if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands' + if (name === 'run_code') return 'code' + if (name === 'web_search') return 'webSearch' + if (name === 'web_fetch') return 'webFetch' + if (name === 'subagent' || name.startsWith('subagent_')) return 'subagents' + if (['todo_write', 'create_goal', 'update_goal', 'get_goal'].includes(name)) return 'plan' + if (name === 'ask_user_question' || name === 'request_user_input') return 'questions' + return 'tools' +} + +const LIVE_TOOL_DETAIL_MAX_CHARS = 160 +const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +const LIVE_TOOL_DETAIL_KEYS = [ + 'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message', + 'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status', +] as const + +function normalizeLiveToolDetail(value: unknown): string { + const text = typeof value === 'string' + ? value + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value.join(', ') + : '' + const normalized = text.replace(/\s+/g, ' ').trim() + const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment) + return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS + ? normalized + : `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…` +} + +function questionDetail(value: unknown): string { + if (!Array.isArray(value)) return '' + for (const item of value) { + if (item === null || typeof item !== 'object') continue + const detail = normalizeLiveToolDetail(Reflect.get(item, 'question')) + if (detail !== '') return detail + } + return '' +} + +function liveReasoningDetail(nodes: readonly ChatNode[]): string { + for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) { + const node = nodes[nodeIndex] + if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue + for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) { + const block = node.data.blocks[blockIndex] + if (block?.kind !== 'reasoning') continue + const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/) + for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) { + const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', '')) + if (detail !== '') return detail + } + } + } + return '' +} + +function liveToolDetail(name: string, argsRaw: string): string { + let args: unknown + try { + args = JSON.parse(argsRaw) + } catch (_error: unknown) { + // Partial or free-form arguments have no safe one-line task detail. + return normalizeLiveToolDetail(name) + } + if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name) + for (const key of LIVE_TOOL_DETAIL_KEYS) { + if (key in args) { + const value: unknown = Reflect.get(args, key) + const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value) + if (detail !== '') return detail + } + } + return normalizeLiveToolDetail(name) +} + +/** + * Rank categories by distinct call count, breaking ties by first appearance. + * @param nodes - process members, including recursive tools. + * @returns all ranked categories and the latest running tool category and bounded task detail. + */ +export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySummary { + const counts = new Map() + const seen = new Set() + let running: ProcessActivity | undefined + let runningDetail = '' + let runningTime = -Infinity + const visit = (tool: ToolCallBlock): void => { + if (seen.has(tool.callId)) return + seen.add(tool.callId) + const call = isRunningTool(tool) ? tool : tool.call + if (call !== null) { + const kind = activity(call.name) + if (isRunningTool(tool) && tool.time >= runningTime) { + running = kind + runningDetail = liveToolDetail(tool.name, tool.argsRaw) + runningTime = tool.time + } + counts.set(kind, (counts.get(kind) ?? 0) + 1) + } + for (const child of tool.subCalls) visit(child) + } + for (const node of nodes) { + if (node.kind === 'tool-call') visit(node.data.root) + } + if (running === undefined) runningDetail = liveReasoningDetail(nodes) + return { + counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count), + running, + runningDetail, + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts new file mode 100644 index 0000000000..4e557d3ca3 --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -0,0 +1,299 @@ +/** Chat-owned segmentation and incremental summaries over materialized Node inputs. */ +import { brandString } from '@deepseek-ai/dsh-brand' +import type { + ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupSnapshot, + GroupUpdate, NodeKey, NodeReference, RenderEntry, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' +import type { ProcessActivitySummary, ProcessGroupData } from '../contract/process-groups.ts' +import { hasAssistantReplyContent } from '../contract/assistant-content.ts' +import { isVisibleChatNode } from '../contract/chat-visibility.ts' +import { processActivity } from './process-activity.ts' + +const INDEPENDENT = new Set(['user', 'steering', 'turn-trigger', 'model-retry', 'turn-error', 'turn-max-tokens', 'turn-tail']) +type ProcessInput = ConversationGroupInput + +function turnOf(node: ChatNode): number | undefined { + const location = node.location + return location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined +} + +function reasoning(node: ChatNode): boolean { + return node.kind === 'assistant-step' + && node.data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') +} + +function reply(node: ChatNode): boolean { + return node.kind === 'assistant-step' && hasAssistantReplyContent(node.data.blocks) +} + +function sameSummary(left: ProcessActivitySummary, right: ProcessActivitySummary): boolean { + return left.running === right.running && left.runningDetail === right.runningDetail + && left.counts.length === right.counts.length && left.counts.every((value, index) => + value.kind === right.counts[index]?.kind && value.count === right.counts[index].count) +} + +function sameMembers(left: readonly NodeReference[], right: readonly NodeReference[]): boolean { + return left.length === right.length && left.every((value, index) => + value.key === right[index]?.key && value.groupPart === right[index].groupPart) +} + +function structureChanged(previous: ChatNode | undefined, current: ChatNode): boolean { + if (!isVisibleChatNode(current) && (previous === undefined || !isVisibleChatNode(previous))) return false + return previous === undefined || previous.kind !== current.kind + || turnOf(previous) !== turnOf(current) + || isVisibleChatNode(previous) !== isVisibleChatNode(current) + || reasoning(previous) !== reasoning(current) || reply(previous) !== reply(current) +} + +function readNode(input: ProcessInput, key: NodeKey): ChatNode { + const node = input.readNode(key) as ChatNode | undefined + if (node === undefined) throw new Error(`Chat grouping input is missing Node ${key}`) + return node +} + +/** One group's members and cached summary, refreshed together when its content changes. */ +class ProcessGroup { + private nodes: readonly ChatNode[] = [] + snapshot: GroupSnapshot + + constructor(readonly key: GroupKey, readonly turn: number, readonly members: readonly NodeReference[]) { + this.snapshot = { key, members, data: { turn, closed: false, summary: { counts: [], running: undefined, runningDetail: '' } } } + } + + refresh(input: ProcessInput, closed: boolean): void { + const nodes = this.members.map(member => readNode(input, member.key)) + const unchanged = nodes.length === this.nodes.length && nodes.every((node, index) => node === this.nodes[index]) + const previous = this.snapshot.data + const activity = unchanged && previous.closed === closed ? previous.summary : processActivity(nodes) + const summary = closed ? { ...activity, running: undefined, runningDetail: '' } : activity + this.nodes = nodes + if (previous.closed !== closed || !sameSummary(previous.summary, summary)) { + this.snapshot = { + key: this.key, members: this.members, + data: { turn: this.turn, closed, summary }, + } + } + } +} + +/** One Turn's grouping result and member lookup; summaries stay with their groups. */ +class TurnGroups { + private groups = new Map() + private membership = new Map() + private roots = new Map() + + constructor(readonly turn: number) {} + + references(key: NodeKey): readonly RenderEntry[] { + return this.roots.get(key) ?? [] + } + + snapshots(): GroupSnapshot[] { + return [...this.groups.values()].map(group => group.snapshot) + } + + refresh(input: ProcessInput, changed: ReadonlySet): GroupSnapshot[] { + const dirty = new Set() + for (const node of changed) { + const group = this.membership.get(node) + if (group !== undefined) dirty.add(group) + } + const ended = input.timeline.turns.get(this.turn)?.status === 'closed' + if (ended) { + for (const group of this.groups.values()) { + if (!group.snapshot.data.closed) dirty.add(group.key) + } + } + const upserts: GroupSnapshot[] = [] + for (const key of dirty) { + const group = this.groups.get(key) as ProcessGroup + const previous = group.snapshot + group.refresh(input, previous.data.closed || ended) + if (group.snapshot !== previous) upserts.push(group.snapshot) + } + return upserts + } + + rebuild(input: ProcessInput, added: ReadonlySet): { upserts: GroupSnapshot[]; removes: GroupKey[] } { + const roots = new Map() + const groups = new Map() + const membership = new Map() + let pending: NodeReference[] = [] + const upserts: GroupSnapshot[] = [] + const emit = (key: NodeKey, entry: RenderEntry): void => { + roots.set(key, [...roots.get(key) ?? [], entry]) + } + const flush = (closed: boolean): void => { + const first = pending[0] + if (first === undefined) return + const retained = this.extendedGroup(pending, added) + const key = retained?.key ?? brandString(JSON.stringify(['process', first.key, first.groupPart ?? null])) + const previous = this.groups.get(key) + const before = previous?.snapshot + const group = previous !== undefined && sameMembers(previous.members, pending) + ? previous : new ProcessGroup(key, this.turn, pending) + group.refresh(input, closed || input.timeline.turns.get(this.turn)?.status === 'closed') + groups.set(group.key, group) + emit(first.key, { kind: 'group', key: group.key }) + for (const member of pending) membership.set(member.key, group.key) + if (group.snapshot !== before) upserts.push(group.snapshot) + pending = [] + } + let previous: NodeKey | undefined + let followed = false + for (const key of input.readTurn(this.turn)) { + const position = readPosition(input, key) + if (previous !== undefined && position.previous !== previous) flush(true) + previous = key + followed = position.next !== undefined + const node = readNode(input, key) + if (INDEPENDENT.has(node.kind)) { + flush(true) + emit(key, { kind: 'node', key }) + } else if (node.kind === 'turn-process') { + emit(key, { kind: 'node', key }) + } else if (node.kind === 'assistant-step') { + if (reasoning(node)) pending.push({ kind: 'node', key, groupPart: 'reasoning' }) + if (reply(node)) { + flush(true) + emit(key, { kind: 'node', key, groupPart: 'response' }) + } + } else pending.push({ kind: 'node', key }) + } + flush(followed) + const removes = [...this.groups.keys()].filter(key => !groups.has(key)) + this.groups = groups + this.membership = membership + this.roots = roots + return { upserts, removes } + } + + private extendedGroup(members: readonly NodeReference[], added: ReadonlySet): ProcessGroup | undefined { + // Reuse identity only while the complete old group remains between newly visible members. + const offset = members.findIndex(member => !added.has(member.key)) + const first = members[offset] + if (first === undefined) return undefined + const key = this.membership.get(first.key) + const previous = key === undefined ? undefined : this.groups.get(key) + if (previous === undefined || offset + previous.members.length > members.length) return undefined + for (let index = 0; index < previous.members.length; index++) { + const before = previous.members[index] as NodeReference + const after = members[offset + index] as NodeReference + if (before.key !== after.key || before.groupPart !== after.groupPart) return undefined + } + for (let index = offset + previous.members.length; index < members.length; index++) { + if (!added.has((members[index] as NodeReference).key)) return undefined + } + return previous + } +} + +function readPosition(input: ProcessInput, key: NodeKey) { + const position = input.readPosition(key) + if (position === undefined) throw new Error(`Chat grouping order is missing position for Node ${key}`) + return position +} + +/** Session-local Turn results; ordinary updates never read other Turns' Node contents. */ +export class ProcessState { + private turns = new Map() + private order: readonly NodeKey[] = [] + private pending: GroupUpdate | null = null + + /** + * Consume one synchronous Builder input without retaining its readers. + * @param input - projected Node changes, indexed positions, and Turn lifecycle. + */ + accept(input: ProcessInput): void { + if (input.kind === 'replace') { + const previousKeys = new Set(this.order) + const added = new Set(input.order.filter(key => !previousKeys.has(key))) + const turns = new Map() + for (const key of input.order) { + const turn = readPosition(input, key).turn + if (turn === undefined || turns.has(turn)) continue + const groups = this.turns.get(turn) ?? new TurnGroups(turn) + groups.rebuild(input, added) + turns.set(turn, groups) + } + this.turns = turns + this.order = input.order + this.pending = { + entries: this.rootEntries(input), + groups: { kind: 'replace', snapshots: [...turns.values()].flatMap(turn => turn.snapshots()) }, + } + return + } + + const regroup = new Set(input.changedTurnOrders) + const added = new Set() + const changed = new Map>() + const touch = (turn: number): Set => { + let keys = changed.get(turn) + if (keys === undefined) { + keys = new Set() + changed.set(turn, keys) + } + return keys + } + for (const change of input.changes) { + const before = change.previous as ChatNode | undefined + const after = change.current as ChatNode + const turn = turnOf(after) + if (before === undefined || !isVisibleChatNode(before)) added.add(after.key as NodeKey) + if (structureChanged(before, after)) { + const previousTurn = before === undefined ? undefined : turnOf(before) + if (previousTurn !== undefined) regroup.add(previousTurn) + if (turn !== undefined) regroup.add(turn) + } + if (turn !== undefined) touch(turn).add(after.key as NodeKey) + } + for (const turn of input.changedTurns) touch(turn) + + const upserts: GroupSnapshot[] = [] + const removes: GroupKey[] = [] + for (const turn of regroup) { + const groups = this.turns.get(turn) ?? new TurnGroups(turn) + const update = groups.rebuild(input, added) + upserts.push(...update.upserts) + removes.push(...update.removes) + if (input.readTurn(turn).length === 0) this.turns.delete(turn) + else this.turns.set(turn, groups) + } + for (const [turn, keys] of changed) { + if (!regroup.has(turn)) upserts.push(...this.turns.get(turn)?.refresh(input, keys) ?? []) + } + const reordered = input.order !== this.order || regroup.size > 0 + this.order = input.order + const installed = new Set(upserts.map(group => group.key)) + this.pending = reordered || upserts.length > 0 || removes.length > 0 + ? { + ...reordered ? { entries: this.rootEntries(input) } : {}, + groups: { kind: 'apply', upserts, removes: removes.filter(key => !installed.has(key)) }, + } + : null + } + + private rootEntries(input: ProcessInput): RenderEntry[] { + return input.order.flatMap((key) => { + const turn = readPosition(input, key).turn + if (turn === undefined) return [{ kind: 'node', key }] + const groups = this.turns.get(turn) + if (groups === undefined) throw new Error(`Chat grouping order is missing Turn ${turn}`) + return groups.references(key) + }) + } + + /** Read pending output without advancing State. @returns the repeatable update for the last input batch. */ + output(): GroupUpdate | null { return this.pending } +} + +/** Chat's registered business grouping; presentation modes never enter its State. */ +export const processGroupDefinition: ConversationGroupDefinition + & { readonly target: 'chat' } = { + kind: 'process-groups', target: 'chat', + create: () => new ProcessState(), + update: (context, input) => { context.state.accept(input); return context.state }, + buildGroups: context => context.state.output(), + } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/register.ts b/packages/client/ui-chat/src/client/conversation-nodes/register.ts index 0754aeb6a0..db94ec45ab 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/register.ts @@ -13,6 +13,7 @@ import { registerTurnErrorConversationNode } from './turn-error.ts' import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts' import { registerTurnProcess } from './turn-process.ts' import { registerTurnTailConversationNode } from './turn-tail.ts' +import { processGroupDefinition } from './process-groups.ts' /** * Register the Chat business Definitions and target builder contributed by this package. @@ -33,4 +34,5 @@ export function registerConversationNodes(ctx: Context): void { registerTurnTailConversationNode(ctx) registerUnknownConversationFallback(ctx) registerChatConversationView(ctx) + ctx.uiConversation.groups.register(processGroupDefinition) } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index 77995c5089..f5426debb3 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -41,7 +41,7 @@ function derivePresentation( for (const key of keys) { const node = nodes.get(key) as ChatNode | undefined if ((node?.kind === 'user' || node?.kind === 'steering' || node?.kind === 'turn-trigger') - && node.anchorSeq < spec.controlAnchorSeq) { + && (spec.controlAnchorSeq === location.turn.start?.seq || node.anchorSeq < spec.controlAnchorSeq)) { openingHumanAnchor = Math.min(openingHumanAnchor ?? node.anchorSeq, node.anchorSeq) } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index 626f9199b2..69f8baa42c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -122,7 +122,7 @@ function latestAnswer(turn: TurnLocation): Readonly | nu } function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null { - const controlAnchorSeq = state.controlAnchorSeq + const controlAnchorSeq = state.controlAnchorSeq ?? turn.start?.seq if (controlAnchorSeq === undefined) return null const answer = latestAnswer(turn) const counts = { diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 017e379ab2..b2a1ca67fe 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -60,3 +60,4 @@ type PublicChatNodeDataMap = ChatNodeDataMap declare module './contract/chat-nodes.ts' { interface ChatNodeDataMap extends PublicChatNodeDataMap {} } +export type { ProcessActivity, ProcessActivitySummary, ProcessGroupData } from './contract/process-groups.ts' diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 572ed93037..f246af9735 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -5,6 +5,34 @@ export const NS = 'chat' /** Simplified Chinese dictionary and key-set source of truth. */ export const zh = { + 'message.stepProcess.thinking': '正在分析请求', + 'message.stepProcess.read': '正在读取文件', + 'message.stepProcess.search': '正在搜索代码', + 'message.stepProcess.edit': '正在编辑文件', + 'message.stepProcess.commands': '正在运行命令', + 'message.stepProcess.code': '正在运行代码', + 'message.stepProcess.webSearch': '正在搜索网页', + 'message.stepProcess.webFetch': '正在访问网页', + 'message.stepProcess.subagents': '正在协调子任务', + 'message.stepProcess.plan': '正在更新计划', + 'message.stepProcess.questions': '等待你的操作', + 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.done.thinking': '已完成分析', + 'message.stepProcess.done.read': '已读取文件', + 'message.stepProcess.done.search': '已搜索代码', + 'message.stepProcess.done.edit': '修改了文件', + 'message.stepProcess.done.commands': '执行了命令', + 'message.stepProcess.done.code': '运行了代码', + 'message.stepProcess.done.webSearch': '已搜索网页', + 'message.stepProcess.done.webFetch': '已访问网页', + 'message.stepProcess.done.subagents': '已协调子任务', + 'message.stepProcess.done.plan': '更新了计划', + 'message.stepProcess.done.questions': '向用户提出了问题', + 'message.stepProcess.done.tools': '已调用工具', + 'message.stepProcess.joinTwo': '{first}并{second}', + 'message.stepProcess.comma': ',', + 'message.stepProcess.sharedPrefix': '已', + 'message.stepProcess.more': '{title}等', 'message.trigger.request': '收到执行请求', 'message.trigger.goal': '继续执行目标', 'message.trigger.agent': '收到任务消息', @@ -16,6 +44,10 @@ export const zh = { 'message.trigger.job': '后台任务状态更新', 'message.trigger.plugin': '插件状态更新', 'message.trigger.explanation': '这条通知触发了本轮回复。', + 'message.turnProcess.worked': '已完成工作', + 'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}', + 'message.turnProcess.took': '用时 {duration}', + 'message.turnProcess.failed': '处理失败', 'view.chat': '对话', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}秒', @@ -33,7 +65,7 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', - 'chat.deepDiving': '深度求索中...', + 'chat.deepDiving': '深度求索中', 'chat.turnNavigation.label': '轮次导航', 'chat.turnNavigation.jump': '跳转到第 {turn} 轮', 'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮', @@ -131,6 +163,34 @@ export type ChatKey = keyof typeof zh /** English dictionary, checked against the Chinese key set. */ export const en = { + 'message.stepProcess.thinking': 'Analyzing the request', + 'message.stepProcess.read': 'Reading files', + 'message.stepProcess.search': 'Searching code', + 'message.stepProcess.edit': 'Editing files', + 'message.stepProcess.commands': 'Running commands', + 'message.stepProcess.code': 'Running code', + 'message.stepProcess.webSearch': 'Searching the web', + 'message.stepProcess.webFetch': 'Visiting web pages', + 'message.stepProcess.subagents': 'Coordinating subagents', + 'message.stepProcess.plan': 'Updating the plan', + 'message.stepProcess.questions': 'Waiting for your action', + 'message.stepProcess.tools': 'Calling tools', + 'message.stepProcess.done.thinking': 'Analysis completed', + 'message.stepProcess.done.read': 'Read files', + 'message.stepProcess.done.search': 'Searched code', + 'message.stepProcess.done.edit': 'Edited files', + 'message.stepProcess.done.commands': 'Ran commands', + 'message.stepProcess.done.code': 'Ran code', + 'message.stepProcess.done.webSearch': 'Searched the web', + 'message.stepProcess.done.webFetch': 'Visited web pages', + 'message.stepProcess.done.subagents': 'Coordinated subagents', + 'message.stepProcess.done.plan': 'Updated the plan', + 'message.stepProcess.done.questions': 'Asked questions', + 'message.stepProcess.done.tools': 'Called tools', + 'message.stepProcess.joinTwo': '{first} and {second}', + 'message.stepProcess.comma': ', ', + 'message.stepProcess.sharedPrefix': '', + 'message.stepProcess.more': '{title}, etc.', 'message.trigger.request': 'Execution requested', 'message.trigger.goal': 'Continuing goal', 'message.trigger.agent': 'Task message received', @@ -142,6 +202,10 @@ export const en = { 'message.trigger.job': 'Background task updated', 'message.trigger.plugin': 'Plugin status updated', 'message.trigger.explanation': 'This notification triggered this response.', + 'message.turnProcess.worked': 'Worked', + 'message.turnProcess.deepDivingFor': 'Deep diving for {duration}', + 'message.turnProcess.took': 'Took {duration}', + 'message.turnProcess.failed': 'Failed', 'view.chat': 'Chat', 'number.groupSeparator': ',', 'duration.compactSeconds': '{seconds}s', diff --git a/packages/client/ui-chat/src/client/presentation-policy.ts b/packages/client/ui-chat/src/client/presentation-policy.ts index c1af53db51..a7653e3038 100644 --- a/packages/client/ui-chat/src/client/presentation-policy.ts +++ b/packages/client/ui-chat/src/client/presentation-policy.ts @@ -13,8 +13,10 @@ export interface ChatPresentationPolicy { readonly mode: TranscriptViewMode /** Whether a normally completed Turn folds its process rows behind the whole-Turn control. */ readonly foldCompletedTurns: boolean - /** Reserved secondary-grouping preference; no renderer consumes this field yet. */ + /** Whether secondary groups expose a collapsible header. */ readonly stepGrouping: 'collapsed' | 'none' + /** Show the running command, path, query, or reasoning detail in group titles. */ + readonly liveProcessDetail: boolean /** Whether a settled reasoning row previews its first line beside the Think title. */ readonly settledReasoningPreview: boolean } @@ -24,18 +26,21 @@ const POLICIES: Readonly> = { mode: 'compact', foldCompletedTurns: true, stepGrouping: 'collapsed', + liveProcessDetail: false, settledReasoningPreview: false, }, detailed: { mode: 'detailed', foldCompletedTurns: true, stepGrouping: 'collapsed', + liveProcessDetail: true, settledReasoningPreview: true, }, expanded: { mode: 'expanded', foldCompletedTurns: true, stepGrouping: 'none', + liveProcessDetail: true, settledReasoningPreview: true, }, } diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 501df3e00d..04dc15407d 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -23,12 +23,6 @@ import { createChatStore } from '../src/client/stores.ts' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { - interface ConversationGroupDataMap { - chat: number - } -} - usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId @@ -56,7 +50,7 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) { +async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, withProcessGroups = true) { const runtime = await SlotTestRuntime.create() const chatSettings = stubConfigForm() if (initialSettings !== undefined) chatSettings.publish({ value: initialSettings }) @@ -107,7 +101,10 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + const registerGroups = withProcessGroups ? undefined + : vi.spyOn(runtime.ctx.uiConversation.groups, 'register').mockImplementation(() => () => {}) const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) + registerGroups?.mockRestore() runtime.renderRoot() const chatViewApi = (reference: SessionReference) => { @@ -133,7 +130,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) describe('Chat inject API', () => { it('resolves keyed Group sources across registration, activation, and removal', async () => { - const b = await bench() + const b = await bench(undefined, true, false) try { const { injected } = b.chatViewApi(b.rootReference) const key = 'injected-group' as GroupKey @@ -145,13 +142,15 @@ describe('Chat inject API', () => { update: () => null, buildGroups: () => ({ entries: [{ kind: 'group', key }], - groups: { kind: 'replace', snapshots: [{ key, data: 1, members: [] }] }, + groups: { kind: 'replace', snapshots: [{ + key, data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } }, members: [], + }] }, }), }) await Promise.resolve() conversation.binding(b.rootReference.binding).activate('chat') const source = injected.keyedHooks.chatGroup(key) - expect(source?.getSnapshot()?.data).toBe(1) + expect(source?.getSnapshot()?.data.turn).toBe(1) expect(injected.keyedHooks.chatGroup(key)).toBe(source) remove() await Promise.resolve() 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 21b0d483ff..979fca2e8d 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -7,6 +7,7 @@ import type { import type { ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts' import { sameTurnNavigationItem, turnNavigationItem, @@ -273,6 +274,7 @@ export function chatSnapshotFixture(input: { readonly partial?: PartialAssistant | null readonly runningCalls?: readonly RunningToolCall[] readonly turnTimings?: LegacyConversationSlice['turnTimings'] + /** Recorded ends exist without starts; missing end times follow the fixture's seq × 1000 convention. */ readonly turnEnds?: LegacyConversationSlice['turnEnds'] /** Per-turn usage buckets; production derives these from session events. */ readonly turnUsages?: ReadonlyMap | undefined @@ -301,11 +303,12 @@ export function chatSnapshotFixture(input: { turns.set(turn, { turn, start: timing === undefined ? undefined : { - type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn, - } as never, - end: timing?.endTime === undefined || endSeq === undefined ? undefined : { - type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed', - } as never, + type: 'turn/start', seq: SessionSeq(Math.max(0, (endSeq ?? 1) - 1)), time: timing.startTime, data: { turn }, + }, + end: endSeq === undefined ? undefined : { + type: 'turn/end', seq: SessionSeq(endSeq), time: timing?.endTime ?? endSeq * 1000, + data: { turn, reason: { kind: 'completed' } }, + }, status: endSeq === undefined ? 'open' : 'closed', steps: EMPTY, data, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f81d6b9aeb..f24b77acd4 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -17,10 +18,10 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConversationGroupedView, - ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey, + ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -46,15 +47,23 @@ import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx' import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts' import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts' import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' -declare module '@deepseek-ai/dsh-client-ui-conversation/client' { - interface ConversationGroupDataMap { - chat: number - } +function installGroupedSnapshot( + builder: ChatSnapshotBuilder, state: ProcessState, groups: ConversationGroupStore, source: ChatSnapshot, +): ChatSnapshot { + const snapshot = builder.replace({ nodes: source.nodes.values(), timeline: source.timeline }) + const input = builder.groupInput() + state.accept(input) + const update = state.output() + if (update !== null) groups.prepareAndInstall(update, input.readNode) + builder.publish() + groups.publish() + return snapshot } // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. @@ -268,8 +277,8 @@ function makeHarness( const useChatNodeProcess = bindKeyedSnapshotSelector( key => chatSource.source.getSnapshot().nodes.processSource(key), ) - let grouped: ConversationGroupedView | undefined - const absentGroup = createSnapshotStore | undefined>(undefined) + let grouped: ConversationGroupedView | undefined + const absentGroup = createSnapshotStore | undefined>(undefined) const conversation = createSnapshotStore({ ...EMPTY_CONVERSATION_SNAPSHOT, views: { @@ -470,7 +479,7 @@ function makeHarness( setOutline: (value: unknown) => { outlineValue = value }, chatScroll, forkAt, toolOwners, setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) }, - setGrouped: (value: ConversationGroupedView | undefined) => { + setGrouped: (value: ConversationGroupedView | undefined) => { grouped = value conversation.set({ ...conversation.getSnapshot() }) }, @@ -484,6 +493,7 @@ function makeHarness( /** Simulate reader input (any device): a delivered position that deviates * from the observed-top ledger of programmatic writes. */ function readerScroll(element: HTMLElement, top: number): void { + fireEvent.wheel(element) element.scrollTop = top fireEvent.scroll(element) fireEvent(element, new Event('scrollend')) @@ -615,7 +625,7 @@ describe('ChatView', () => { const h = makeHarness({}, {}, snapshot) const [included, omitted] = snapshot.order if (included === undefined || omitted === undefined) throw new Error('expected two Nodes') - const groupStore = new ConversationGroupStore() + const groupStore = new ConversationGroupStore() groupStore.prepareAndInstall({ entries: [{ kind: 'node', key: included as NodeKey }], groups: { kind: 'replace', snapshots: [] }, @@ -634,8 +644,8 @@ describe('ChatView', () => { const [outside, inside] = snapshot.order.map(key => ({ kind: 'node' as const, key: key as NodeKey })) if (outside === undefined || inside === undefined) throw new Error('expected two Nodes') const key = 'process' as GroupKey - const groupStore = new ConversationGroupStore() - const record = { key, data: 0, members: [inside] } + const groupStore = new ConversationGroupStore() + const record = { key, data: { turn: 1, closed: false, summary: { counts: [], running: undefined, runningDetail: '' } }, members: [inside] } groupStore.prepareAndInstall({ entries: [outside, { kind: 'group', key }], groups: { kind: 'replace', snapshots: [record] }, @@ -648,19 +658,32 @@ describe('ChatView', () => { return }) as ChatViewSlotProps['renderSlot']) const view = render() + const groupHeader = view.container.querySelector('[data-chat-group-key] [data-process-activity]')! + fireEvent.click(groupHeader) const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement const parent = input.closest('[data-chat-group-key]') expect(parent).not.toBeNull() expect(parent?.tagName).toBe('DIV') fireEvent.change(input, { target: { value: 'retained local input' } }) + const observe = vi.spyOn(ResizeObserver.prototype, 'observe') for (const mode of ['detailed', 'expanded', 'compact'] as const) { act(() => { h.setTranscriptView(mode) }) expect(view.getByRole('textbox', { name: inside.key })).toBe(input) expect(input.value).toBe('retained local input') expect(input.closest('[data-chat-group-key]')).toBe(parent) + expect(observe).not.toHaveBeenCalled() } + fireEvent.click(groupHeader) + expect(groupHeader.getAttribute('aria-expanded')).toBe('false') + act(() => { h.setTranscriptView('expanded') }) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) + act(() => { h.setTranscriptView('compact') }) + expect(view.queryByRole('textbox', { name: inside.key })).toBeNull() + expect(groupHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(groupHeader) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) act(() => { - groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: 1 }], removes: [] } }, id => snapshot.nodes.get(id)) + groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: { ...record.data, closed: true } }], removes: [] } }, id => snapshot.nodes.get(id)) groupStore.publish() }) expect(view.getByRole('textbox', { name: inside.key })).toBe(input) @@ -679,10 +702,10 @@ describe('ChatView', () => { const h = makeHarness({}, {}, snapshot) const nodeKey = snapshot.order.find(key => snapshot.nodes.get(key)?.kind === 'assistant-step') as NodeKey const key = 'parts' as GroupKey - const groupStore = new ConversationGroupStore() + const groupStore = new ConversationGroupStore() groupStore.prepareAndInstall({ entries: [{ kind: 'group', key }, { kind: 'node', key: nodeKey, groupPart: 'response' }], - groups: { kind: 'replace', snapshots: [{ key, data: 0, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] }, + groups: { kind: 'replace', snapshots: [{ key, data: { turn: 1, closed: false, summary: { counts: [], running: undefined, runningDetail: '' } }, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] }, }, id => snapshot.nodes.get(id)) h.setGrouped(groupStore) h.setNodeRenderer(((slot: string, owner: object) => { @@ -704,10 +727,10 @@ describe('ChatView', () => { const h = makeHarness({}, {}, snapshot) const key = 'retained-group' as GroupKey const members = snapshot.order.map(id => ({ kind: 'node' as const, key: id as NodeKey })) - const groups = new ConversationGroupStore() + const groups = new ConversationGroupStore() groups.prepareAndInstall({ entries: [{ kind: 'group', key }], - groups: { kind: 'replace', snapshots: [{ key, data: 0, members }] }, + groups: { kind: 'replace', snapshots: [{ key, data: { turn: 1, closed: false, summary: { counts: [], running: undefined, runningDetail: '' } }, members }] }, }, id => snapshot.nodes.get(id)) groups.publish() h.setGrouped(groups) @@ -1038,6 +1061,7 @@ describe('ChatView', () => { Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) readerScroll(scroller, 50) fireEvent.click(view.getByText('加载更早')) + act(() => { h.set({ loadingOlder: true }) }) // The reader moves after the request starts; this, not the click-time // row, is the intent the arriving page must preserve. firstTop = -200 @@ -1046,11 +1070,149 @@ describe('ChatView', () => { Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true }) nextTop = 560 act(() => { - h.setChat({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] }) + h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')], loadingOlder: false }) }) expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift }) + it.each([ + { mode: 'compact', anchor: 'group' }, { mode: 'detailed', anchor: 'group' }, + { mode: 'compact', anchor: 'node' }, { mode: 'detailed', anchor: 'node' }, { mode: 'expanded', anchor: 'node' }, + ] as const)( + 'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => { + const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 }) + const initial = { nodes: [tool(4, 'retained'), assistant(6, 'answer', 1, 2)], turnEnds: new Map([[1, 7]]) } + let source = chatSnapshotFixture(initial) + const builder = new ChatSnapshotBuilder() + const state = new ProcessState() + const groups = new ConversationGroupStore() + const h = makeHarness({ chat: installGroupedSnapshot(builder, state, groups, source), hasMore: true }) + h.setGrouped(groups) + h.setTranscriptView(mode) + const view = render() + const control = turnProcessControl(view.container)! + const controlSeat = control.closest('[data-chat-flow-key]')! + fireEvent.click(control) + const group = view.container.querySelector('[data-chat-group-key]')! + const header = group.querySelector('[data-process-activity]')! + if (anchor === 'node' && mode !== 'expanded') fireEvent.click(header) + const body = group.querySelector('[data-step-process-body]')! + const old = view.container.querySelector('[data-chat-node-key="fixture:tool:retained"]')! + const pinned = anchor === 'group' ? group : old + const expectedTop = anchor === 'group' ? 30 : 60 + const expectedScroll = anchor === 'group' ? 200 : 300 + const scroller = view.container.querySelector('[class*="scroll"]')! + installScrollMetrics(scroller, 1500, 400) + let paged = false + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === scroller) return new DOMRect(0, 0, 500, 400) + const top = this === old ? 60 + (paged ? 300 : 0) + : this === body ? 60 + (paged ? 200 : 0) + : this === group ? 30 + (paged ? 200 : 0) : 0 + return new DOMRect(0, top - scroller.scrollTop, 500, 24) + }) + Object.defineProperties(body, { + clientHeight: { get: () => paged ? 200 : 100 }, + scrollHeight: { get: () => paged ? 200 : 100 }, + }) + fireEvent.click(view.getByText('加载更早')) + expect(pinned.closest('[hidden]')).toBeNull() + expect(pinned.hasAttribute('data-chat-paging-anchor')).toBe(true) + expect(controlSeat.hasAttribute('data-chat-paging-anchor')).toBe(false) + expect(group.hasAttribute('data-chat-paging-anchor')).toBe(mode !== 'expanded' && anchor !== 'node') + act(() => { + paged = true + source = chatSnapshotFixture({ ...initial, nodes: [ + tool(1, 'earlier'), steering(2, 'historical direction', 1), tool(3, 'before-retained'), ...initial.nodes, + ] }, source) + h.set({ chat: installGroupedSnapshot(builder, state, groups, source) }) + h.setGrouped(groups) + }) + + expect(pinned.getBoundingClientRect().top).toBe(expectedTop) + expect(scroller.scrollTop).toBe(expectedScroll) + expect(view.container.querySelector('[data-chat-node-key="fixture:tool:retained"]')).toBe(old) + expect(old.closest('[data-chat-group-key]')).toBe(group) + expect(control.getAttribute('aria-expanded')).toBe('true') + if (mode !== 'expanded') expect(header.getAttribute('aria-expanded')).toBe(String(anchor === 'node')) + expect(view.container.querySelector('[data-chat-flow-kind="steering"]')?.closest('[data-chat-group-key]')).toBeNull() + expect(h.chatScroll.read()?.anchorKey).toBe(pinned.dataset.chatAnchorKey) + }, + ) + + it.each([ + { mode: 'compact', hasProcess: false, hasSteering: false }, + { mode: 'detailed', hasProcess: false, hasSteering: false }, + { mode: 'expanded', hasProcess: false, hasSteering: false }, + { mode: 'compact', hasProcess: true, hasSteering: false }, + { mode: 'detailed', hasProcess: true, hasSteering: false }, + { mode: 'expanded', hasProcess: true, hasSteering: false }, + { mode: 'compact', hasProcess: false, hasSteering: true }, + { mode: 'detailed', hasProcess: false, hasSteering: true }, + { mode: 'expanded', hasProcess: false, hasSteering: true }, + ] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => { + const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 }) + const initial = { + nodes: [ + ...hasProcess ? [tool(4, 'loaded-work')] : [], + ...hasSteering ? [steering(5, 'retained direction', 1)] : [], + assistant(6, 'retained answer', 1, 2), + userInTurn(9, 'next question', 2), assistant(10, 'next answer', 2), + userInTurn(13, 'last question', 3), assistant(14, 'last answer', 3), + ], + turnEnds: new Map([[1, 7], [2, 11], [3, 15]]), + } + let source = chatSnapshotFixture(initial) + const builder = new ChatSnapshotBuilder() + const state = new ProcessState() + const groups = new ConversationGroupStore() + const h = makeHarness({ chat: installGroupedSnapshot(builder, state, groups, source), hasMore: true }) + h.setGrouped(groups) + h.setTranscriptView(mode) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]')! + const column = scroller.querySelector('[data-chat-flow]')! + const answer = view.container.querySelector('[data-chat-node-key="fixture:assistant:6"]')! + const first = hasSteering ? view.container.querySelector('[data-chat-node-key="fixture:steering:5"]')! : answer + const control = view.container.querySelector('[data-chat-node-key="fixture:turn-process:1"]')! + // jsdom has no layout; positions follow the actual committed row order and hidden state. + const visibleRows = () => [...column.children].filter((element): element is HTMLElement => + element instanceof HTMLElement && element.hasAttribute('data-chat-flow-key') + && !element.hasAttribute('hidden') && !element.matches(':empty')) + Object.defineProperties(scroller, { + clientHeight: { value: 200 }, + scrollHeight: { get: () => 40 + visibleRows().length * 40 }, + }) + scroller.scrollTop = 0 + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === scroller) return new DOMRect(0, 0, 500, 200) + const index = visibleRows().indexOf(this) + return new DOMRect(0, 40 + index * 40 - scroller.scrollTop, 500, index < 0 ? 0 : 40) + }) + const top = first.getBoundingClientRect().top + if (hasSteering) expect(visibleRows().indexOf(first)).toBeLessThan(visibleRows().indexOf(control)) + expect(control.hasAttribute('data-chat-paging-anchor')).toBe(false) + fireEvent.click(view.getByText('加载更早')) + + act(() => { + source = chatSnapshotFixture({ ...initial, nodes: [ + tool(1, 'earlier-work'), steering(2, 'earlier direction', 1), tool(3, 'following-work'), ...initial.nodes, + ] }, source) + h.set({ chat: installGroupedSnapshot(builder, state, groups, source) }) + h.setGrouped(groups) + }) + + const steer = view.container.querySelector('[data-chat-node-key="fixture:steering:2"]')! + const rows = visibleRows() + expect(rows.indexOf(control)).toBeLessThan(rows.indexOf(steer)) + expect(rows.indexOf(steer)).toBeLessThan(rows.indexOf(answer)) + expect(first.getBoundingClientRect().top).toBe(top) + expect(scroller.scrollTop).toBe(hasSteering ? 80 : 40) + expect(h.chatScroll.read()?.anchorKey).toBe(first.dataset.chatAnchorKey) + expect(first.isConnected).toBe(true) + expect(view.container.querySelector('[data-chat-node-key="fixture:assistant:6"]')).toBe(answer) + }) + it('bounds no-anchor hit testing before using the mounted-row fallback', () => { const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint') const hitTest = vi.fn((): Element[] => []) @@ -1084,7 +1246,7 @@ describe('ChatView', () => { } }) - it('falls back to the first visible row when the viewport top hit-test misses', () => { + it('anchors the first transcript row for paging regardless of the viewport reading line', () => { const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint') const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`)) const h = makeHarness( @@ -1119,16 +1281,16 @@ describe('ChatView', () => { try { rowRectCalls = 0 fireEvent.click(view.getByText('加载更早')) - expect(hitTest).toHaveBeenCalledTimes(1) - expect(hitTest.mock.calls[0]?.[1]).toBe(1) - expect(rowRectCalls).toBeLessThanOrEqual(6) + expect(hitTest).not.toHaveBeenCalled() + expect(rowRectCalls).toBe(1) Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true }) prepended = true act(() => { h.setChat({ nodes: [assistant(2, 'older'), ...nodes] }) }) - expect(scroller.scrollTop).toBe(450) // reader offset 50 + first visible row's 400px shift + expect(scroller.scrollTop).toBe(550) + expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:20') } finally { if (originalHitTest !== undefined) { Object.defineProperty(document, 'elementsFromPoint', originalHitTest) @@ -1910,11 +2072,10 @@ describe('ChatView', () => { expect(contextRow?.getAttribute('hidden')).toBe('until-found') }) - it('withholds folding until the Turn start arrives even when older history remains', () => { + it('folds a recorded Turn end independently of remaining history or a loaded start', () => { const h = makeHarness({ nodes: [ user(1, 'question'), - context(2, 'runtime policy', 1), assistant(3, 'working', 1, 1), assistant(4, 'final answer', 1, 2), ], @@ -1922,26 +2083,141 @@ describe('ChatView', () => { hasMore: true, }) const view = render() - const contextRow = view.container.querySelector('[data-chat-flow-kind="context"]') - - expect(turnProcessControl(view.container)).toBeNull() - expect(contextRow?.getAttribute('hidden')).toBeNull() - expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false) + const row = view.container.querySelector('[data-chat-node-key="fixture:assistant:3"]')! + const toggle = turnProcessControl(view.container)! + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toBe(h.props.t('message.turnProcess.worked')) + expect(row.getAttribute('hidden')).toBe('until-found') + expect(view.getByText('final answer')).toBeTruthy() act(() => { h.set({ hasMore: false }) }) - expect(turnProcessControl(view.container)).toBeNull() - expect(contextRow?.getAttribute('hidden')).toBeNull() + expect(turnProcessControl(view.container)).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(row.getAttribute('hidden')).toBe('until-found') act(() => { h.set({ hasMore: true, turnTimings: new Map([[1, { startTime: 0, endTime: 5_000 }]]), }) }) - const toggle = turnProcessControl(view.container)! + expect(turnProcessControl(view.container)).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(contextRow?.getAttribute('hidden')).toBe('until-found') + expect(toggle.textContent).toBe(h.props.t('message.turnProcess.took', { duration: formatRunDuration(5_000, h.props.t) })) + expect(row.getAttribute('hidden')).toBe('until-found') }) - it('withholds process controls for partial history and folds final-page groups', () => { + it.each([ + { grouped: false, manualOpen: false }, { grouped: false, manualOpen: true }, + { grouped: true, manualOpen: false }, { grouped: true, manualOpen: true }, + ])('retains paged Turn visibility before and after loading its start (grouped=$grouped, open=$manualOpen)', ({ grouped, manualOpen }) => { + const source = chatSnapshotFixture({ + nodes: [reasoningAssistant(2, 'loaded reasoning'), toolResult(3, 'partial'), assistant(4, 'loaded final answer', 1, 2)], + turnEnds: new Map([[1, 5]]), + }) + const ended = source.timeline.turns.get(1) + if (ended?.end === undefined || ended.start !== undefined) throw new Error('expected only a Turn end') + const nodesAt = (turn: TurnLocation, nodes = source.nodes.values()) => nodes.map(node => ( + node.location.kind === 'turn' || node.location.kind === 'step' + ? { ...node, location: { ...node.location, turn } } + : node + )) + const builder = new ChatSnapshotBuilder() + const snapshot = builder.replace({ + nodes: nodesAt(ended), timeline: { turnOrder: [1], turns: new Map([[1, ended]]) }, + }) + const state = new ProcessState() + const groups = new ConversationGroupStore() + const updateGroups = () => { + const input = builder.groupInput() + state.accept(input) + const update = state.output() + if (update !== null) groups.prepareAndInstall(update, input.readNode) + } + updateGroups() + const h = makeHarness({ chat: snapshot, hasMore: true }) + if (grouped) h.setGrouped(groups) + const view = render() + const toggle = view.getByRole('button', { name: h.props.t('message.turnProcess.worked') }) + let row = view.container.querySelector('[data-chat-node-key="fixture:tool:partial"]')! + let group = view.container.querySelector('[data-chat-group-key]') + for (const mode of ['compact', 'detailed', 'expanded'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(row.getAttribute('hidden')).toBe('until-found') + if (grouped) expect(group?.getAttribute('hidden')).toBe('until-found') + expect(view.getByText('loaded final answer')).toBeTruthy() + } + if (manualOpen) fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe(String(manualOpen)) + expect(row.hasAttribute('hidden')).toBe(!manualOpen) + if (grouped) expect(group?.hasAttribute('hidden')).toBe(!manualOpen) + const beforePageRow = row + const beforePageGroup = group + const groupToggle = group?.querySelector('[data-process-activity]') + if (grouped && manualOpen) { + act(() => { h.setTranscriptView('detailed') }) + fireEvent.click(groupToggle!) + expect(groupToggle?.getAttribute('aria-expanded')).toBe('true') + } + + let older = source + let paged = ended + act(() => { + older = chatSnapshotFixture({ + nodes: [reasoningAssistant(1, 'earlier loaded reasoning'), ...source.legacy.nodes], + turnEnds: new Map([[1, 5]]), + }, source) + const turn = older.timeline.turns.get(1) + if (turn?.end === undefined || turn.start !== undefined) throw new Error('expected start to remain outside the page') + paged = turn + const next = builder.apply({ + upserts: nodesAt(paged, older.nodes.values()), timeline: { turnOrder: [1], turns: new Map([[1, paged]]) }, changedTurns: [1], + }) + updateGroups() + builder.publish() + groups.publish() + h.set({ chat: next, hasMore: true }) + }) + row = view.container.querySelector('[data-chat-node-key="fixture:tool:partial"]')! + group = view.container.querySelector('[data-chat-group-key]') + expect(row).toBe(beforePageRow) + expect(group).toBe(beforePageGroup) + if (grouped && manualOpen) { + expect(group?.querySelector('[data-process-activity]')).toBe(groupToggle) + expect(groupToggle?.getAttribute('aria-expanded')).toBe('true') + } + expect(turnProcessControl(view.container)).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe(String(manualOpen)) + expect(row.hasAttribute('hidden')).toBe(!manualOpen) + if (grouped) expect(group?.hasAttribute('hidden')).toBe(!manualOpen) + expect(view.container.querySelector('[data-chat-node-key="fixture:assistant:1"]')?.hasAttribute('hidden')).toBe(!manualOpen) + expect(view.getByText('loaded final answer')).toBeTruthy() + + const complete: TurnLocation = { + ...paged, start: { type: 'turn/start', seq: SessionSeq(0), time: 1_000, data: { turn: 1 } }, + } + act(() => { + const next = builder.apply({ + upserts: nodesAt(complete, older.nodes.values()), timeline: { turnOrder: [1], turns: new Map([[1, complete]]) }, changedTurns: [1], + }) + updateGroups() + builder.publish() + groups.publish() + h.set({ chat: next, hasMore: true }) + }) + expect(turnProcessControl(view.container)).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe(String(manualOpen)) + expect(view.container.querySelector('[data-chat-node-key="fixture:tool:partial"]')).toBe(row) + expect(row.hasAttribute('hidden')).toBe(!manualOpen) + if (grouped) { + expect(view.container.querySelector('[data-chat-group-key]')).toBe(group) + expect(group?.hasAttribute('hidden')).toBe(!manualOpen) + } + expect(toggle.textContent).toBe(h.props.t('message.turnProcess.took', { duration: formatRunDuration(4_000, h.props.t) })) + act(() => { h.set({ hasMore: false }) }) + expect(toggle.getAttribute('aria-expanded')).toBe(String(manualOpen)) + }) + + it('withholds process controls without a loaded Turn boundary and folds completed groups', () => { const h = makeHarness({ nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)], hasMore: true, diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts index 09303fdde9..7353f5f7ee 100644 --- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -37,6 +37,7 @@ function fixture() { element.dataset.chatNodeKey = nodeKey element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part]) element.dataset.chatFlowKey = element.dataset.chatAnchorKey + element.dataset.chatPagingAnchor = '' element.dataset.chatTurn = '1' parent.append(element) vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => @@ -46,6 +47,73 @@ function fixture() { return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } } } +it.each([1, 1000])('selects the first paging marker without measuring other rows (%s later rows)', (count) => { + const h = fixture() + const unmarked = h.row(h.column, 'expanded-control', 0) + delete unmarked.dataset.chatPagingAnchor + const first = h.row(h.column, 'first', -100) + const later = Array.from({ length: count }, (_, index) => h.row(h.column, `later-${index}`, 10 + index * 60)) + for (const row of [unmarked, ...later]) { + vi.mocked(row.getBoundingClientRect).mockImplementation(() => { throw new Error('measured an unselected row') }) + } + const hitTest = vi.fn(() => { throw new Error('paging used a visual hit test') }) + Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: hitTest }) + const candidates = vi.spyOn(h.column, 'querySelectorAll') + + h.viewport.beginPaging() + + expect(hitTest).not.toHaveBeenCalled() + expect(candidates).not.toHaveBeenCalled() + expect(first.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.column.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.viewport.preserve()?.position).toMatchObject({ anchorKey: 'first', anchorTop: -100 }) +}) + +it('skips hidden and empty paging markers without reading their geometry', () => { + const h = fixture() + const hidden = h.row(h.column, 'hidden', 0) + hidden.setAttribute('hidden', 'until-found') + const parent = h.group() + parent.setAttribute('hidden', 'until-found') + const child = h.row(parent, 'hidden-child', 20) + const empty = h.row(h.column, 'empty', 40) + empty.textContent = '' + const visible = h.row(h.column, 'visible', 60) + for (const row of [hidden, child, empty]) { + vi.mocked(row.getBoundingClientRect).mockImplementation(() => { throw new Error('measured an ineligible row') }) + } + + h.viewport.beginPaging() + + expect(visible.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.viewport.preserve()?.position?.anchorKey).toBe('visible') +}) + +it('does not measure the viewport when no paging marker is available', () => { + const h = fixture() + h.viewport.beginPaging() + expect(h.viewport.preserving).toBe(false) + expect(h.column.getBoundingClientRect).not.toHaveBeenCalled() +}) + +it('keeps a collapsed header stationary for hidden growth and content inserted before it', () => { + const h = fixture() + const header = h.row(h.column, 'collapsed-header', 40) + const body = document.createElement('div') + body.setAttribute('hidden', 'until-found') + header.append(body) + h.row(body, 'hidden-member', 70) + h.viewport.beginPaging() + + h.row(body, 'older-hidden-member', 50) + expect(h.viewport.preserve()?.metrics.top).toBe(0) + expect(header.getBoundingClientRect().top).toBe(40) + + h.prepend(200) + expect(h.viewport.preserve()?.metrics.top).toBe(200) + expect(header.getBoundingClientRect().top).toBe(40) +}) + it('captures a grouped member and preserves its reading position after history grows above it', () => { const h = fixture() const group = h.group() @@ -56,12 +124,59 @@ it('captures a grouped member and preserves its reading position after history g const position = h.viewport.capturePosition() expect(position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 100 }) if (position === null) throw new Error('expected a grouped reading anchor') + h.viewport.beginPreserving(position) h.prepend(200) - const landing = h.viewport.preserve(position) + const landing = h.viewport.preserve() expect(landing?.metrics.top).toBe(300) expect(landing?.position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 300 }) }) +it('anchors an open group to its old content even when the pointer ray hits its header', () => { + const h = fixture() + const group = h.group() + group.dataset.chatAnchorKey = 'group' + const header = document.createElement('button') + group.append(header) + const body = document.createElement('div') + body.dataset.stepProcessBody = '' + group.append(body) + const content = document.createElement('div') + content.dataset.stepProcessContent = '' + body.append(content) + h.row(content, 'old-member', 40) + Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: () => [header] }) + + const position = h.viewport.capturePosition() + expect(position?.anchorKey).toBe('old-member') + if (position === null) throw new Error('expected a member anchor') + h.viewport.beginPreserving(position) + h.prepend(120) + expect(h.viewport.preserve()?.metrics.top).toBe(120) + body.setAttribute('hidden', 'until-found') + expect(h.viewport.capturePosition()?.anchorKey).toBe('group') +}) + +it.each([false, true])('ignores a relocating Turn control when paging reveals a steering boundary (hit=%s)', (hit) => { + const h = fixture() + const control = h.row(h.column, 'turn-control', 0) + control.dataset.chatFlowKind = 'turn-process' + vi.mocked(control.getBoundingClientRect).mockImplementation(() => new DOMRect(0, -h.column.scrollTop, 500, 24)) + const group = h.group() + group.dataset.chatAnchorKey = 'group' + const old = h.row(group, 'old-message', 60) + if (hit) Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: () => [control] }) + + const position = h.viewport.capturePosition() + h.viewport.beginPreserving(position) + const steering = h.row(h.column, 'steering', 30) + h.column.insertBefore(steering, group) + h.prepend(200) + h.viewport.preserve() + expect(old.getBoundingClientRect().top).toBe(60) + expect(h.column.scrollTop).toBe(200) + expect(position?.anchorKey).toBe('old-message') +}) + it('navigates by Node identity while retaining a distinct visible part anchor', () => { const h = fixture() const group = h.group() @@ -112,3 +227,176 @@ it('retains the original capture and Turn navigation path for ungrouped Nodes', expect(h.viewport.capturePosition()?.anchorKey).toBe('whole') expect(h.viewport.scrollToTurn(1)?.metrics.top).toBe(66) }) + +it('retains a semantic paging row when segmentation remounts its DOM', () => { + const h = fixture() + const original = h.row(h.group(), 'retained', 90) + h.viewport.beginPreserving() + original.remove() + const replacement = h.row(h.group(), 'retained', 290) + + expect(h.viewport.preserve()?.metrics.top).toBe(200) + expect(replacement.getBoundingClientRect().top).toBe(90) + h.prepend(100) + expect(h.viewport.preserve()?.metrics.top).toBe(300) + expect(replacement.getBoundingClientRect().top).toBe(90) +}) + +function nestedFixture(height: number, cap: number, trailing = 1000, viewportHeight = 300) { + const scroller = document.createElement('div') + scroller.dataset.conversationScroll = '' + const column = document.createElement('div') + const body = document.createElement('div') + body.dataset.stepProcessBody = '' + const content = document.createElement('div') + content.dataset.stepProcessContent = '' + const row = document.createElement('div') + row.dataset.chatAnchorKey = 'retained' + row.dataset.chatPagingAnchor = '' + row.textContent = 'retained content' + content.append(row) + body.append(content) + column.append(body) + scroller.append(column) + document.body.append(scroller) + let outerTop = 0 + let innerTop = 0 + let prefix = 0 + const bodyHeight = () => Math.min(height + prefix, cap) + const columnHeight = () => 100 + bodyHeight() + trailing + Object.defineProperties(scroller, { + clientHeight: { value: viewportHeight }, + scrollHeight: { get: () => Math.max(viewportHeight, columnHeight()) }, + scrollTop: { + get: () => outerTop, + set: (value: number) => { outerTop = Math.max(0, Math.min(value, scroller.scrollHeight - viewportHeight)) }, + }, + }) + Object.defineProperties(body, { + clientHeight: { get: bodyHeight }, + scrollHeight: { get: () => height + prefix }, + scrollTop: { + get: () => innerTop, + set: (value: number) => { innerTop = Math.max(0, Math.min(value, body.scrollHeight - bodyHeight())) }, + }, + }) + vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 0, 500, viewportHeight)) + vi.spyOn(column, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, -outerTop, 500, columnHeight())) + vi.spyOn(body, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - outerTop, 500, bodyHeight())) + vi.spyOn(content, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - outerTop - innerTop, 500, height + prefix)) + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 200 + prefix - outerTop - innerTop, 500, 40)) + const observed = new Set() + let notifyResize = () => {} + class Observer implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } } + observe(target: Element): void { observed.add(target) } + unobserve(target: Element): void { observed.delete(target) } + disconnect(): void { observed.clear() } + } + vi.stubGlobal('ResizeObserver', Observer) + const viewport = new ChatViewport() + viewport.attach(column, column) + viewport.connect({ scroll: () => {}, scrollEnd: () => {}, resize: () => { viewport.preserve() }, interact: () => {} }) + onTestFinished(() => { + viewport.detach() + scroller.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + const begin = () => { + const top = row.getBoundingClientRect().top + viewport.beginPreserving({ anchorKey: 'retained', anchorTop: top, scrollTop: scroller.scrollTop }) + return top + } + return { + scroller, column, body, content, row, viewport, observed, begin, + prepend: (amount: number) => { prefix += amount }, + resize: () => { notifyResize() }, + } +} + +it('measures only the selected member and its inner and outer containers once when paging starts', () => { + const h = nestedFixture(600, 400) + h.viewport.beginPaging() + expect(h.row.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.body.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.scroller.getBoundingClientRect).toHaveBeenCalledTimes(1) + expect(h.column.getBoundingClientRect).not.toHaveBeenCalled() + expect(h.content.getBoundingClientRect).not.toHaveBeenCalled() +}) + +it.each([ + { height: 200, cap: 400, prefix: 100, inner: 0, expectedInner: 0, expectedOuter: 180 }, + { height: 300, cap: 400, prefix: 200, inner: 0, expectedInner: 100, expectedOuter: 180 }, + { height: 600, cap: 400, prefix: 200, inner: 40, expectedInner: 240, expectedOuter: 80 }, + { height: 600, cap: Infinity, prefix: 200, inner: 0, expectedInner: 0, expectedOuter: 280 }, +])('preserves one anchor across inner and outer scrolling (height=$height, cap=$cap)', (test) => { + const h = nestedFixture(test.height, test.cap) + h.scroller.scrollTop = 80 + h.body.scrollTop = test.inner + const top = h.begin() + h.prepend(test.prefix) + h.viewport.preserve() + expect(h.body.scrollTop).toBe(test.expectedInner) + expect(h.scroller.scrollTop).toBe(test.expectedOuter) + expect(h.row.getBoundingClientRect().top).toBe(top) +}) + +it.each(['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'])( + 'retains the same paging anchor through later content resizes until %s', (intent) => { + const h = nestedFixture(200, 400) + h.scroller.scrollTop = 80 + const top = h.begin() + expect(h.observed.has(h.content)).toBe(true) + for (const amount of [100, 100, 100]) { + h.prepend(amount) + h.resize() + expect(h.row.getBoundingClientRect().top).toBe(top) + } + h.body.dispatchEvent(new Event(intent, { bubbles: true })) + expect(h.viewport.preserving).toBe(false) + expect(h.observed.has(h.content)).toBe(false) + const previousTop = h.scroller.scrollTop + h.prepend(100) + h.resize() + expect(h.scroller.scrollTop).toBe(previousTop) + }) + +it('does not compensate twice when native inner anchoring already held the old row', () => { + const h = nestedFixture(600, 400) + h.scroller.scrollTop = 80 + h.body.scrollTop = 40 + const top = h.begin() + h.prepend(200) + h.body.scrollTop = 240 + h.viewport.preserve() + expect(h.body.scrollTop).toBe(240) + expect(h.scroller.scrollTop).toBe(80) + expect(h.row.getBoundingClientRect().top).toBe(top) +}) + +it.each([ + { viewportHeight: 800, prefix: 100, outer: 0 }, + { viewportHeight: 450, prefix: 200, outer: 50 }, +])('clamps paging to the natural scroll range without adding space (viewport=$viewportHeight)', ({ viewportHeight, prefix, outer }) => { + const h = nestedFixture(200, 400, 0, viewportHeight) + h.begin() + h.prepend(prefix) + const landing = h.viewport.preserve() + expect(h.scroller.scrollTop).toBe(outer) + expect(h.row.getBoundingClientRect().top).toBe(200 + prefix - outer) + expect(landing?.position?.anchorTop).toBe(h.row.getBoundingClientRect().top) + expect(h.column.getAttribute('style')).toBeNull() + h.resize() + expect(h.scroller.scrollTop).toBe(outer) +}) + +it('releases a removed paging row without navigating to unrelated content', () => { + const h = nestedFixture(600, 400) + h.scroller.scrollTop = 80 + h.begin() + h.row.remove() + expect(h.viewport.preserve()).toBeNull() + expect(h.viewport.preserving).toBe(false) + expect(h.scroller.scrollTop).toBe(80) +}) diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts new file mode 100644 index 0000000000..8fdc292da1 --- /dev/null +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { SessionSeq } from '@deepseek-ai/dsh-session/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { ConversationTimelineSnapshot, TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts' +import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' +import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' + +const turn: TurnLocation = { + turn: 1, status: 'open', steps: [], end: undefined, + start: { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }, + data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) }, +} +const timeline: ConversationTimelineSnapshot = { turnOrder: [1], turns: new Map([[1, turn]]) } +const secondTurn: TurnLocation = { + ...turn, turn: 2, + start: { type: 'turn/start', seq: SessionSeq(10), time: 10, data: { turn: 2 } }, +} +const twoTurns: ConversationTimelineSnapshot = { turnOrder: [1, 2], turns: new Map([[1, turn], [2, secondTurn]]) } + +function assistant(text: string, response = ''): ChatNode<'assistant-step'> { + return { + key: 'assistant', id: 'assistant', kind: 'assistant-step', target: 'chat', anchorSeq: 2, + location: { kind: 'turn', turn }, visibility: 'visible', + data: { + status: 'running', turn: 1, step: 1, time: 2, + blocks: [{ kind: 'reasoning', text }, ...response === '' ? [] : [{ kind: 'text' as const, text: response }]], + }, + } +} + +function tool(key: string, seq: number, name = 'bash', owner = turn): ChatNode<'tool-call'> { + return { + key, id: key, kind: 'tool-call', target: 'chat', anchorSeq: seq, + location: { kind: 'turn', turn: owner }, visibility: 'visible', + data: { root: { callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } }, + } +} + +function separator(seq: number, inTurn: boolean): ChatNode<'user'> | ChatNode<'steering'> { + const user: ChatNode<'user'> = { + key: `user:${seq}`, id: `user:${seq}`, kind: 'user', target: 'chat', anchorSeq: seq, + location: inTurn ? { kind: 'turn', turn } : { kind: 'session' }, visibility: 'visible', + data: { kind: 'user', seq, time: seq, content: [{ type: 'text', text: 'continue' }], source: null }, + } + return inTurn ? { ...user, kind: 'steering', data: { ...user.data, kind: 'steering', messageId: 'steer' as MessageId } } : user +} + +function harness(nodes: ChatNode[], currentTimeline = timeline) { + const builder = new ChatSnapshotBuilder() + const state = new ProcessState() + const store = new ConversationGroupStore() + const commit = () => { + const input = builder.groupInput() + state.accept(input) + const update = state.output() + if (update !== null) store.prepareAndInstall(update, input.readNode) + builder.publish() + store.publish() + return update + } + const snapshot = builder.replace({ nodes, timeline: currentTimeline }) + commit() + return { builder, state, store, snapshot, commit } +} + +describe('Definition-owned Chat process groups', () => { + it('retains a group through repeated prepends and a later append', () => { + const b = tool('b', 4) + const c = tool('c', 6) + const h = harness([b, c]) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + const a = tool('a', 3, 'read') + h.builder.apply({ upserts: [a], timeline }) + h.commit() + expect(h.store.entries).toEqual([first]) + expect(source.getSnapshot()?.members.map(member => member.key)).toEqual(['a', 'b', 'c']) + + const earlier = tool('earlier', 2) + h.builder.replace({ nodes: [earlier, a, b, c], timeline }) + h.commit() + expect(h.store.entries).toEqual([first]) + h.builder.apply({ upserts: [tool('later', 7)], timeline }) + h.commit() + expect(h.store.entries).toEqual([first]) + expect(h.store.groupSource(first.key)).toBe(source) + expect(source.getSnapshot()?.members.map(member => member.key)).toEqual(['earlier', 'a', 'b', 'c', 'later']) + }) + + it.each(['reply', 'steering'] as const)('keeps a %s boundary between newly loaded work and an existing group', (boundary) => { + const h = harness([tool('retained', 4)]) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + const previous = source.getSnapshot() + const upserts = boundary === 'reply' ? [assistant('older thought', 'older reply')] + : [assistant('older thought'), separator(3, true)] + h.builder.apply({ upserts, timeline }) + h.commit() + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) + expect(h.store.entries[2]).toEqual(first) + expect(source.getSnapshot()).toBe(previous) + }) + + it('splits an extended group without reusing one identity for two groups', () => { + const h = harness([tool('b', 4), tool('c', 6)]) + h.builder.apply({ upserts: [tool('a', 2)], timeline }) + h.commit() + h.builder.apply({ upserts: [separator(5, true)], timeline }) + h.commit() + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) + const groups = h.store.entries.filter(entry => entry.kind === 'group') + expect(new Set(groups.map(group => group.key)).size).toBe(2) + expect(groups.map(group => h.store.groupSource(group.key).getSnapshot()?.members.map(member => member.key))) + .toEqual([['a', 'b'], ['c']]) + }) + + it('resegments only the changed Turn without notifying historical Node or Group sources', () => { + const h = harness([tool('history', 2), tool('live', 12, 'read', secondTurn)], twoTurns) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + const previous = source.getSnapshot() + const groupChanged = vi.fn() + const nodeChanged = vi.fn() + const stopGroup = source.subscribe(groupChanged) + const stopNode = h.snapshot.nodes.source('history').subscribe(nodeChanged) + try { + h.builder.apply({ upserts: [tool('new', 13, 'bash', secondTurn)], timeline: twoTurns }) + const input = h.builder.groupInput() + const readNode = vi.fn(input.readNode) + const readTurn = vi.fn(input.readTurn) + h.state.accept({ ...input, readNode, readTurn }) + const update = h.state.output() + if (update === null) throw new Error('expected new group member') + h.store.prepareAndInstall(update, input.readNode) + h.builder.publish() + h.store.publish() + expect(new Set(readTurn.mock.calls.map(([turn]) => turn))).toEqual(new Set([2])) + expect(readNode.mock.calls.map(([key]) => key)).not.toContain('history') + expect(source.getSnapshot()).toBe(previous) + expect(groupChanged).not.toHaveBeenCalled() + expect(nodeChanged).not.toHaveBeenCalled() + } finally { + stopGroup() + stopNode() + } + }) + + it('closes only the ended Turn without reading another Turn or rebuilding roots', () => { + const h = harness([tool('history', 2), tool('live', 12, 'bash', secondTurn)], twoTurns) + const roots = h.store.entries + const ended: TurnLocation = { + ...secondTurn, status: 'closed', + end: { type: 'turn/end', seq: SessionSeq(14), time: 14, data: { turn: 2, reason: { kind: 'completed' } } }, + } + h.builder.apply({ + upserts: [], timeline: { turnOrder: [1, 2], turns: new Map([[1, turn], [2, ended]]) }, changedTurns: [2], + }) + const input = h.builder.groupInput() + const readNode = vi.fn(input.readNode) + const readTurn = vi.fn(input.readTurn) + h.state.accept({ ...input, readNode, readTurn }) + const update = h.state.output() + if (update === null) throw new Error('expected closed group') + h.store.prepareAndInstall(update, input.readNode) + h.store.publish() + expect(readTurn).not.toHaveBeenCalled() + expect(readNode.mock.calls.map(([key]) => key)).toEqual(['live']) + expect(update.entries).toBeUndefined() + expect(h.store.entries).toBe(roots) + }) + + it('splits and rejoins one Turn when an unscoped separator enters and leaves its order', () => { + const h = harness([tool('a', 2), tool('b', 4)]) + const boundary = separator(3, false) + h.builder.apply({ upserts: [boundary], timeline }) + const split = h.builder.groupInput() + if (split.kind !== 'apply') throw new Error('expected apply') + expect(split.changedTurnOrders).toEqual([1]) + h.commit() + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) + h.builder.apply({ upserts: [{ ...boundary, visibility: 'hidden' }], timeline }) + h.commit() + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group']) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + expect(h.store.groupSource(first.key).getSnapshot()?.members.map(member => member.key)).toEqual(['a', 'b']) + }) + + it('moves a group head between Turns in one atomic group update', () => { + const h = harness([tool('moving', 2), tool('second', 12, 'bash', secondTurn)], twoTurns) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + h.builder.apply({ upserts: [tool('moving', 2, 'bash', secondTurn)], timeline: twoTurns }) + h.commit() + expect(h.store.entries).toEqual([first]) + expect(h.store.groupSource(first.key).getSnapshot()?.data.turn).toBe(2) + expect(h.store.groupSource(first.key).getSnapshot()?.members.map(member => member.key)).toEqual(['moving', 'second']) + }) + + it('rereads only the changed group on a content update', () => { + const h = harness([tool('history', 2, 'read'), separator(3, true), tool('live', 4)]) + const history = h.store.entries[0]! + if (history.kind !== 'group') throw new Error('expected group') + const previous = h.store.groupSource(history.key).getSnapshot() + const live = tool('live', 4) + h.builder.apply({ + upserts: [{ ...live, data: { root: { ...live.data.root, argsRaw: '{"command":"ls"}' } } }], + timeline, + }) + const input = h.builder.groupInput() + const readNode = vi.fn(input.readNode) + h.state.accept({ ...input, readNode }) + const update = h.state.output() + if (update === null) throw new Error('expected updated detail') + h.store.prepareAndInstall(update, input.readNode) + h.store.publish() + expect(readNode.mock.calls.map(([key]) => key)).toEqual(['live']) + expect(update.entries).toBeUndefined() + expect(h.store.groupSource(history.key).getSnapshot()).toBe(previous) + expect(h.state.output()).toBe(update) + }) + + it('rebuilds ranges while retaining unchanged groups and recomputing category order', () => { + const a = tool('a', 2, 'read') + const b = tool('b', 3) + const c = tool('c', 4) + const h = harness([a, b, c, separator(5, true), tool('later', 6)]) + const first = h.store.entries[0]! + const last = h.store.entries[2]! + if (first.kind !== 'group' || last.kind !== 'group') throw new Error('expected groups') + const lastSnapshot = h.store.groupSource(last.key).getSnapshot() + expect(h.store.groupSource(first.key).getSnapshot()?.data.summary.counts).toEqual([ + { kind: 'commands', count: 2 }, { kind: 'read', count: 1 }, + ]) + h.builder.apply({ upserts: [{ ...c, visibility: 'hidden' }], timeline }) + h.commit() + expect(h.store.groupSource(first.key).getSnapshot()?.data.summary.counts).toEqual([ + { kind: 'read', count: 1 }, { kind: 'commands', count: 1 }, + ]) + expect(h.store.groupSource(last.key).getSnapshot()).toBe(lastSnapshot) + }) + + it('restores live detail when replacement removes a closing separator', () => { + const a = tool('a', 2) + const h = harness([a, separator(3, false)]) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + expect(source.getSnapshot()?.data.closed).toBe(true) + expect(source.getSnapshot()?.data.summary.runningDetail).toBe('') + h.builder.replace({ nodes: [a], timeline }) + h.commit() + expect(source.getSnapshot()?.data.closed).toBe(false) + expect(source.getSnapshot()?.data.summary.running).toBe('commands') + expect(source.getSnapshot()?.data.summary.runningDetail).toBe('pwd') + }) + + it('splits reasoning from replies and starts later groups after tools and steering in one Step', () => { + const { store } = harness([separator(0, true), assistant('think', 'progress'), tool('a', 3), separator(4, true), tool('b', 5)]) + expect(store.entries.map(entry => entry.kind)).toEqual(['node', 'group', 'node', 'group', 'node', 'group']) + const groups = store.entries.filter(entry => entry.kind === 'group') + expect(groups).toHaveLength(3) + expect(store.groupSource(groups[0]!.key).getSnapshot()?.members).toEqual([ + { kind: 'node', key: 'assistant', groupPart: 'reasoning' }, + ]) + expect(store.entries[2]).toEqual({ kind: 'node', key: 'assistant', groupPart: 'response' }) + expect(store.groupSource(groups[1]!.key).getSnapshot()?.members.map(member => member.key)).toEqual(['a']) + expect(store.groupSource(groups[2]!.key).getSnapshot()?.members.map(member => member.key)).toEqual(['b']) + }) + + it('updates live detail without replacing roots or members and closes on the first reply', () => { + const h = harness([assistant('first thought')]) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + const entries = h.store.entries + const members = source.getSnapshot()!.members + h.builder.apply({ upserts: [assistant('second thought')], timeline }) + expect(h.commit()?.entries).toBeUndefined() + expect(h.store.entries).toBe(entries) + expect(source.getSnapshot()!.members).toBe(members) + expect(source.getSnapshot()!.data.summary.runningDetail).toBe('second thought') + h.builder.apply({ upserts: [assistant('second thought', 'reply')], timeline }) + h.commit() + expect(h.store.entries[0]).toEqual(first) + expect(source.getSnapshot()!.data.closed).toBe(true) + expect(h.store.entries[1]).toMatchObject({ kind: 'node', groupPart: 'response' }) + const closed = source.getSnapshot() + const completed = assistant('second thought', 'settled reply') + h.builder.apply({ upserts: [{ ...completed, data: { ...completed.data, status: 'settled' } }], timeline }) + expect(h.commit()).toBeNull() + expect(source.getSnapshot()).toBe(closed) + }) + + it('keeps a no-Turn row between groups and repairs membership on replacement', () => { + const a = tool('a', 2) + const b = tool('b', 4) + const h = harness([a, separator(3, false), b]) + expect(h.store.entries.map(entry => entry.kind)).toEqual(['group', 'node', 'group']) + const removed = h.store.entries[2]! + if (removed.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(removed.key) + const head = h.store.entries[0]! + if (head.kind !== 'group') throw new Error('expected group') + h.builder.apply({ upserts: [{ ...b, visibility: 'hidden' }], timeline }) + h.commit() + expect(h.store.entries).toHaveLength(2) + expect(h.store.groupSource(head.key).getSnapshot()?.data.closed).toBe(true) + expect(source.getSnapshot()).toBeUndefined() + h.builder.replace({ nodes: [a, b], timeline }) + h.commit() + expect(h.store.entries).toHaveLength(1) + expect(source.getSnapshot()).toBeUndefined() + expect(h.builder.groupInput().order).toHaveLength(2) + }) + + it('closes an active group on a lifecycle-only update', () => { + const h = harness([tool('a', 2)]) + const first = h.store.entries[0]! + if (first.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(first.key) + const ended: TurnLocation = { + ...turn, status: 'closed', + end: { type: 'turn/end', seq: SessionSeq(3), time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, + } + h.builder.apply({ upserts: [], timeline: { turnOrder: [1], turns: new Map([[1, ended]]) }, changedTurns: [1] }) + expect(h.commit()?.entries).toBeUndefined() + expect(source.getSnapshot()?.data.closed).toBe(true) + }) +}) diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 523ea04624..a0f8a5047f 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -9,6 +9,9 @@ "src" ], "references": [ + { + "path": "../../util/brand" + }, { "path": "../../util/values" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 7e89ff22ec..0be6b2133f 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:269', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:270', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:257', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:258', }, { key: 'conversation.chat.node', @@ -294,7 +294,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:238', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:239', }, { key: 'conversation.chat.turnTail', @@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:263', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:264', }, { key: 'conversation.composer', @@ -1168,7 +1168,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:251', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:252', }, { key: 'conversation.plan-review.actions', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65d4761b2e..5a01769828 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3072,6 +3072,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From 376f68199eff5a323973ebd69b86d3892235ba18 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 20:25:25 +0800 Subject: [PATCH 04/25] perf(chat): narrow group title updates and defer edge measurements --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 3 +- .../ui-chat/tests/chat-view.client.spec.tsx | 96 +++++++++++++++++++ 5 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3a59ce8e2c..73ff226ebe 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: 777cc097326cb0d7b75e1c7eb9d35e0060d3476a -README.zh.md: d5490216e34b62da778a28493285396d89a66a84 +README.md: 689fb0511b237de899509caab36e15c7f8dd68c8 +README.zh.md: 3cfe383e93e25b5c87123e4ba3c7b8b1e3553de5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 777cc09732..689fb0511b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -73,12 +73,14 @@ Work-details modes control process-group display and reasoning previews; eligibl ## Grouped rendering -Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries. +Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries. `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition. +Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect. + Each group owns local `useDisclosure` state that survives mode changes while its component stays mounted. The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index d5490216e3..3cfe383e93 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -73,12 +73,14 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通 ## 分组渲染 -Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。 +Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 +滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。 + 每个组拥有本地 `useDisclosure` 状态,组件保持挂载时,模式切换保留该状态。 Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。 diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 4597238c66..84bbc545fc 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -107,7 +107,7 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG readonly toggle: () => void }) { const data = useChatGroup(groupKey, group => group?.data) - const detailed = usePresentation(policy => policy.liveProcessDetail) + const detailed = usePresentation(policy => data?.closed === false && policy.liveProcessDetail) const live = useStableLiveProcessTitle({ activity: data?.summary.running ?? 'thinking', detail: data?.summary.runningDetail ?? '', @@ -172,7 +172,6 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou }, [bodyRef]) // The content box reports growth even when the scroll body remains height-capped. useLayoutEffect(() => { - sync() const body = bodyRef.current if (body === null || !open || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(sync) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f24b77acd4..5fec4be8e3 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -697,6 +697,102 @@ describe('ChatView', () => { expect(view.queryByRole('textbox', { name: outside.key })).toBeNull() }) + it('waits for observed layout before measuring an opened process group', () => { + const observers = new Set() + class Observer implements ResizeObserver { + readonly targets = new Set() + constructor(readonly callback: ResizeObserverCallback) { observers.add(this) } + observe(target: Element): void { this.targets.add(target) } + unobserve(target: Element): void { this.targets.delete(target) } + disconnect(): void { observers.delete(this) } + } + vi.stubGlobal('ResizeObserver', Observer) + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'inside')] }) + const h = makeHarness({}, {}, snapshot) + const key = 'observed-process' as GroupKey + const groups = new ConversationGroupStore() + groups.prepareAndInstall({ + entries: [{ kind: 'group', key }], + groups: { kind: 'replace', snapshots: [{ + key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }], + data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } }, + }] }, + }, key => snapshot.nodes.get(key)) + h.setGrouped(groups) + const view = render() + const header = view.container.querySelector('[data-process-activity]')! + const body = view.container.querySelector('[data-step-process-body]')! + let reads = 0 + let top = 0 + Object.defineProperties(body, { + scrollTop: { get: () => { reads++; return top } }, + clientHeight: { get: () => { reads++; return 200 } }, + scrollHeight: { get: () => { reads++; return 600 } }, + }) + + fireEvent.click(header) + expect(reads).toBe(0) + const observer = [...observers].find(observer => observer.targets.has(body))! + expect(observer).toBeDefined() + act(() => { observer.callback([], observer) }) + expect(body.hasAttribute('data-scroll-up')).toBe(false) + expect(body.hasAttribute('data-scroll-down')).toBe(true) + top = 400 + fireEvent.scroll(body) + expect(body.hasAttribute('data-scroll-up')).toBe(true) + expect(body.hasAttribute('data-scroll-down')).toBe(false) + const measured = reads + fireEvent.click(header) + expect(reads).toBe(measured) + expect(observers.has(observer)).toBe(false) + }) + + it('does not recalculate settled group titles when work-details mode changes', () => { + const snapshot = chatSnapshotFixture({ nodes: Array.from({ length: 41 }, (_, index) => user(index + 1, `member ${index}`)) }) + const h = makeHarness({}, {}, snapshot) + const groupStore = new ConversationGroupStore() + const groups = snapshot.order.map((nodeKey, index): GroupSnapshot => ({ + key: `settled-${index}` as GroupKey, + data: { + turn: 1, closed: index < 40, + summary: { counts: [], running: index < 40 ? undefined : 'commands', runningDetail: index < 40 ? '' : 'pwd' }, + }, + members: [{ kind: 'node', key: nodeKey as NodeKey }], + })) + groupStore.prepareAndInstall({ + entries: groups.map(group => ({ kind: 'group', key: group.key })), + groups: { kind: 'replace', snapshots: groups }, + }, key => snapshot.nodes.get(key)) + h.setGrouped(groupStore) + const translate = vi.fn(h.props.t) + const view = render() + const allHeaders = [...view.container.querySelectorAll('[data-process-activity]')] + const headers = allHeaders.slice(0, 40) + expect(headers).toHaveLength(40) + const titles = headers.map(header => header.textContent) + + for (const mode of ['detailed', 'expanded', 'compact'] as const) { + translate.mockClear() + act(() => { h.setTranscriptView(mode) }) + expect(translate.mock.calls.filter(([key]) => key === 'message.stepProcess.thinking')).toHaveLength(0) + expect([...view.container.querySelectorAll('[data-process-activity]')]).toEqual(allHeaders) + expect(headers.map(header => header.textContent)).toEqual(titles) + const liveLabel = h.props.t('message.stepProcess.commands') + expect(allHeaders[40]?.textContent).toBe(mode === 'compact' ? liveLabel + : `${liveLabel}${h.props.t('message.turnProcess.separator')}pwd`) + } + const running = groups[40]! + act(() => { + groupStore.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [{ + ...running, data: { ...running.data, closed: true }, + }] } }, key => snapshot.nodes.get(key)) + groupStore.publish() + }) + translate.mockClear() + act(() => { h.setTranscriptView('detailed') }) + expect(translate.mock.calls.filter(([key]) => key.startsWith('message.stepProcess.'))).toHaveLength(0) + }) + it('passes independently keyed group parts to business Node renderers', () => { const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] }) const h = makeHarness({}, {}, snapshot) From 3a046413dec340034e997285b272b49838a16155 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 20:26:22 +0800 Subject: [PATCH 05/25] perf(locale): avoid unchanged document language writes --- packages/client/locale/README.i18n.yaml | 4 ++-- packages/client/locale/README.md | 2 ++ packages/client/locale/README.zh.md | 2 ++ packages/client/locale/src/client/index.ts | 3 ++- .../tests/document-language.client.spec.ts | 17 +++++++++++++++++ 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index b7ac9fbd69..647303d0b2 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/locale/README.md -README.md: c9c6c86201d7618178cdcfe50790747ebd75fde4 -README.zh.md: f97265b4a9ff58955f4b8720091d7460f2a7d397 +README.md: a21f61dd931204f990e6865419ac19476c957ff4 +README.zh.md: c93b555f96e459ddad56faaf46d267cf319218ad diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index c9c6c86201..a21f61dd93 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -89,6 +89,8 @@ The provisional locale comes from the browser (`navigator.languages` matched by ### Dictionary lookup +Document language synchronization writes `` only when its value changes; dictionary-only revisions leave the attribute untouched. + The typed object form requires complete dictionaries for both built-in locales. The per-locale form lets language packs register each namespace independently. For each key, lookup walks the active language's declared fallback chain in the requested namespace, repeats that chain in `common`, then displays the key itself. Bound translate functions retain stable identity per namespace so they can ride inject surfaces without breaking memoization. ### Source map diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index f97265b4a9..c93b555f96 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -89,6 +89,8 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意 ### 字典查找 +文档语言同步只在值变化时写入 ``;仅更新字典的 revision 不改动该属性。 + 带类型的对象形式要求两个内置 locale 都有完整字典;逐 locale 形式允许语言包独立注册每个命名空间。逐键查找会先在请求命名空间中沿生效语言声明的 fallback 链查找,再在 `common` 中重复该链,最后显示键本身。绑定的翻译函数按命名空间保持稳定身份,因此可通过 inject 机制传递,且不会破坏 memoization。 ### 源码地图 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index fceba74d99..744a5f54e6 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -148,7 +148,8 @@ function normalizeLanguage(input: LanguageRegistration): Readonly { locale.setLocale('pt-BR') expect(langOf()).toBe('pt-BR') }) + + it('does not rewrite the document language for catalog changes with the same active locale', async () => { + const { locale } = await bench() + const observer = new MutationObserver(() => {}) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] }) + try { + const remove = locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + expect(observer.takeRecords()).toHaveLength(0) + remove() + expect(observer.takeRecords()).toHaveLength(0) + locale.setLocale('en') + expect(observer.takeRecords()).toHaveLength(1) + expect(langOf()).toBe('en') + } finally { + observer.disconnect() + } + }) }) From a7fc7d4f5612726f7264049d112af72c165fce42 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 20:27:05 +0800 Subject: [PATCH 06/25] perf(chat): restore preference focus before publishing updates --- packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 ++ packages/client/ui-chat/README.zh.md | 2 ++ .../src/client/settings/PreferenceRow.tsx | 7 ++++-- .../tests/transcript-view-row.client.spec.tsx | 24 +++++++++++++++++++ 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 73ff226ebe..c261aea6f4 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: 689fb0511b237de899509caab36e15c7f8dd68c8 -README.zh.md: 3cfe383e93e25b5c87123e4ba3c7b8b1e3553de5 +README.md: b1de52097e09583d7f9655ac86b7ea0d5a769b68 +README.zh.md: ad1498fc7ae3aca28e606e38902dc0b19432f74f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 689fb0511b..b1de52097e 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -66,6 +66,8 @@ The completed-turn action footer follows the recorded Turn end. Its action row s ## Turn Process Folding +Preference menus restore focus to their trigger without scrolling before publishing a new selection. + Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3cfe383e93..ad1498fc7a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -66,6 +66,8 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行、普通 ## 轮次过程折叠 +偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。 + 工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 ----- diff --git a/packages/client/ui-chat/src/client/settings/PreferenceRow.tsx b/packages/client/ui-chat/src/client/settings/PreferenceRow.tsx index c5b216b455..bfad7b5316 100644 --- a/packages/client/ui-chat/src/client/settings/PreferenceRow.tsx +++ b/packages/client/ui-chat/src/client/settings/PreferenceRow.tsx @@ -1,10 +1,10 @@ /** Localized two-column selector shared by Chat preference rows. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PreferenceRow.module.css' /** - * Render a preference label and its menu. + * Render a preference label and its menu; selection restores focus before publishing the new value. * @param props - localized copy, selected value, choices, and mutation callback. * @returns the settings row. */ @@ -17,13 +17,16 @@ export function PreferenceRow({ title, description, value, selectedLabel, option onSelect: (value: string) => void }) { const [open, setOpen] = useState(false) + const selectorRef = useRef(null) const closeMenu = () => { setOpen(false) } const selectMode = (id: string) => { + selectorRef.current?.focus({ preventScroll: true }) closeMenu() onSelect(id) } const selector = (