diff --git a/packages/docs/src/pages/components/think/demo/destroy-on-hidden.vue b/packages/docs/src/pages/components/think/demo/destroy-on-hidden.vue new file mode 100644 index 00000000..b268967f --- /dev/null +++ b/packages/docs/src/pages/components/think/demo/destroy-on-hidden.vue @@ -0,0 +1,38 @@ + + + + + +通过外部按钮控制展开状态,并切换 `destroyOnHidden` 对折叠内容的处理方式。 + + + +Control the expanded state externally and switch how collapsed content is handled with `destroyOnHidden`. + diff --git a/packages/docs/src/pages/components/think/index.en-US.md b/packages/docs/src/pages/components/think/index.en-US.md index 283f2f5a..71e0b07b 100644 --- a/packages/docs/src/pages/components/think/index.en-US.md +++ b/packages/docs/src/pages/components/think/index.en-US.md @@ -13,23 +13,25 @@ Used to show deep thinking process. Status Expand Slots +Destroy On Hidden ## API ### ThinkProps -| Property | Description | Type | Default | Version | -| ------------------ | ---------------------------- | ----------------------------------------------------- | ------- | ------- | -| classes | Semantic DOM class | [Record\](#semantic-dom) | - | - | -| styles | Semantic DOM style | [Record\](#semantic-dom) | - | - | -| default | Content (default slot) | `VNodeChild` | - | - | -| title | Text of status | `VNodeChild` | - | - | -| icon | Show icon | `VNodeChild` | - | - | -| loading | Loading | `boolean \| VNodeChild` | `false` | - | -| defaultExpanded | Default Expand state | `boolean` | `true` | - | -| expanded (v-model) | Expand state | `boolean` | - | - | -| onExpand | Callback when expand changes | `(expand: boolean) => void` | - | - | -| blink | Blink mode | `boolean` | - | - | +| Property | Description | Type | Default | Version | +| ------------------ | -------------------------------------------------- | ----------------------------------------------------- | ------- | ------- | +| classes | Semantic DOM class | [Record\](#semantic-dom) | - | - | +| styles | Semantic DOM style | [Record\](#semantic-dom) | - | - | +| default | Content (default slot) | `VNodeChild` | - | - | +| title | Text of status | `VNodeChild` | - | - | +| icon | Show icon | `VNodeChild` | - | - | +| loading | Loading | `boolean \| VNodeChild` | `false` | - | +| defaultExpanded | Default Expand state | `boolean` | `true` | - | +| expanded (v-model) | Expand state | `boolean` | - | - | +| onExpand | Callback when expand changes | `(expand: boolean) => void` | - | - | +| blink | Blink mode | `boolean` | - | - | +| destroyOnHidden | Whether to destroy the content node when collapsed | `boolean` | `true` | - | ### Think Slots diff --git a/packages/docs/src/pages/components/think/index.zh-CN.md b/packages/docs/src/pages/components/think/index.zh-CN.md index 6e72d65a..8be36002 100644 --- a/packages/docs/src/pages/components/think/index.zh-CN.md +++ b/packages/docs/src/pages/components/think/index.zh-CN.md @@ -14,23 +14,25 @@ description: 展示大模型深度思考过程。 设置状态 是否展开 插槽用法 +折叠时销毁内容 ## API ### ThinkProps -| 属性 | 说明 | 类型 | 默认值 | 版本 | -| ------------------ | ---------------- | --------------------------------------------------- | ------- | ---- | -| classes | 语义化结构类名 | [Record\](#语义化-dom) | - | - | -| styles | 语义化结构样式 | [Record\](#语义化-dom) | - | - | -| default | 内容(默认插槽) | `VNodeChild` | - | - | -| title | 状态文本 | `VNodeChild` | - | - | -| icon | 状态图标 | `VNodeChild` | - | - | -| loading | 加载中 | `boolean \| VNodeChild` | `false` | - | -| defaultExpanded | 默认是否展开 | `boolean` | `true` | - | -| expanded (v-model) | 是否展开 | `boolean` | - | - | -| onExpand | 展开事件 | `(expand: boolean) => void` | - | - | -| blink | 闪动模式 | `boolean` | - | - | +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| ------------------ | ---------------------- | --------------------------------------------------- | ------- | ---- | +| classes | 语义化结构类名 | [Record\](#语义化-dom) | - | - | +| styles | 语义化结构样式 | [Record\](#语义化-dom) | - | - | +| default | 内容(默认插槽) | `VNodeChild` | - | - | +| title | 状态文本 | `VNodeChild` | - | - | +| icon | 状态图标 | `VNodeChild` | - | - | +| loading | 加载中 | `boolean \| VNodeChild` | `false` | - | +| defaultExpanded | 默认是否展开 | `boolean` | `true` | - | +| expanded (v-model) | 是否展开 | `boolean` | - | - | +| onExpand | 展开事件 | `(expand: boolean) => void` | - | - | +| blink | 闪动模式 | `boolean` | - | - | +| destroyOnHidden | 折叠时是否销毁内容节点 | `boolean` | `true` | - | ### Think 插槽 diff --git a/packages/docs/src/pages/components/thought-chain/demo/destroy-on-hidden.vue b/packages/docs/src/pages/components/thought-chain/demo/destroy-on-hidden.vue new file mode 100644 index 00000000..b194b013 --- /dev/null +++ b/packages/docs/src/pages/components/thought-chain/demo/destroy-on-hidden.vue @@ -0,0 +1,55 @@ + + + + + +节点的 `destroyOnHidden` 默认为 `true`,折叠时销毁内容节点。设为 `false` 时内容节点保留在 DOM 中,仅隐藏,可以保留流式输出与内部组件状态(例如上面输入框中已输入的内容)。 + + + +The node level `destroyOnHidden` defaults to `true`, which destroys the content node on collapse. Set it to `false` to keep the node mounted and merely hidden, preserving streaming output and inner component state (such as the text typed into the input above). + diff --git a/packages/docs/src/pages/components/thought-chain/index.en-US.md b/packages/docs/src/pages/components/thought-chain/index.en-US.md index 90053bb7..423b371d 100644 --- a/packages/docs/src/pages/components/thought-chain/index.en-US.md +++ b/packages/docs/src/pages/components/thought-chain/index.en-US.md @@ -19,6 +19,7 @@ description: The ThoughtChain component is used to visualize and track the call Slots Nested Usage Single Row +Destroy On Hidden ## API @@ -50,17 +51,18 @@ description: The ThoughtChain component is used to visualize and track the call ### ThoughtChainItemType -| Property | Description | Type | Default | Version | -| ----------- | ---------------------------------------------------- | ---------------------------------------------- | ----------- | ------- | -| content | Content of the thought node | `VNodeChild` | - | - | -| description | Description of the thought node | `VNodeChild` | - | - | -| footer | Footer of the thought node | `VNodeChild` | - | - | -| icon | Icon of the thought node, not displayed when `false` | `false \| VNodeChild` | DefaultIcon | - | -| key | Unique identifier for the thought node | `string` | - | - | -| status | Status of the thought node | `'loading' \| 'success' \| 'error' \| 'abort'` | - | - | -| title | Title of the thought node | `VNodeChild` | - | - | -| collapsible | Whether the thought node is collapsible | `boolean` | `false` | - | -| blink | Blink mode | `boolean` | - | - | +| Property | Description | Type | Default | Version | +| --------------- | ---------------------------------------------------- | ---------------------------------------------- | ----------- | ------- | +| content | Content of the thought node | `VNodeChild` | - | - | +| description | Description of the thought node | `VNodeChild` | - | - | +| footer | Footer of the thought node | `VNodeChild` | - | - | +| icon | Icon of the thought node, not displayed when `false` | `false \| VNodeChild` | DefaultIcon | - | +| key | Unique identifier for the thought node | `string` | - | - | +| status | Status of the thought node | `'loading' \| 'success' \| 'error' \| 'abort'` | - | - | +| title | Title of the thought node | `VNodeChild` | - | - | +| collapsible | Whether the thought node is collapsible | `boolean` | `false` | - | +| blink | Blink mode | `boolean` | - | - | +| destroyOnHidden | Whether to destroy the content node when collapsed | `boolean` | `true` | - | ### ThoughtChain.Item diff --git a/packages/docs/src/pages/components/thought-chain/index.zh-CN.md b/packages/docs/src/pages/components/thought-chain/index.zh-CN.md index 53f337a9..a915b0ec 100644 --- a/packages/docs/src/pages/components/thought-chain/index.zh-CN.md +++ b/packages/docs/src/pages/components/thought-chain/index.zh-CN.md @@ -20,6 +20,7 @@ description: 思维链组件用于可视化和追踪 Agent 对 Actions 和 Tools 插槽自定义 嵌套使用 单行折叠 +折叠时销毁内容 ## API @@ -51,17 +52,18 @@ description: 思维链组件用于可视化和追踪 Agent 对 Actions 和 Tools ### ThoughtChainItemType -| 属性 | 说明 | 类型 | 默认值 | 版本 | -| ----------- | --------------------------------- | ---------------------------------------------- | ----------- | ---- | -| content | 思维节点内容 | `VNodeChild` | - | - | -| description | 思维节点描述 | `VNodeChild` | - | - | -| footer | 思维节点脚注 | `VNodeChild` | - | - | -| icon | 思维节点图标,为 `false` 时不展示 | `false \| VNodeChild` | DefaultIcon | - | -| key | 思维节点唯一标识符 | `string` | - | - | -| status | 思维节点状态 | `'loading' \| 'success' \| 'error' \| 'abort'` | - | - | -| title | 思维节点标题 | `VNodeChild` | - | - | -| collapsible | 思维节点是否可折叠 | `boolean` | `false` | - | -| blink | 闪动效果 | `boolean` | - | - | +| 属性 | 说明 | 类型 | 默认值 | 版本 | +| --------------- | --------------------------------- | ---------------------------------------------- | ----------- | ---- | +| content | 思维节点内容 | `VNodeChild` | - | - | +| description | 思维节点描述 | `VNodeChild` | - | - | +| footer | 思维节点脚注 | `VNodeChild` | - | - | +| icon | 思维节点图标,为 `false` 时不展示 | `false \| VNodeChild` | DefaultIcon | - | +| key | 思维节点唯一标识符 | `string` | - | - | +| status | 思维节点状态 | `'loading' \| 'success' \| 'error' \| 'abort'` | - | - | +| title | 思维节点标题 | `VNodeChild` | - | - | +| collapsible | 思维节点是否可折叠 | `boolean` | `false` | - | +| blink | 闪动效果 | `boolean` | - | - | +| destroyOnHidden | 折叠时是否销毁内容节点 | `boolean` | `true` | - | ### ThoughtChain.Item diff --git a/packages/x/components/think/Think.tsx b/packages/x/components/think/Think.tsx index 51da8731..7284f519 100644 --- a/packages/x/components/think/Think.tsx +++ b/packages/x/components/think/Think.tsx @@ -8,7 +8,9 @@ import { defineComponent, ref, useAttrs, + vShow, watch, + withDirectives, } from "vue"; import type { SemanticType, ThinkProps, ThinkRef } from "./interface"; @@ -69,6 +71,10 @@ export const XThink = defineComponent({ type: Boolean, default: false, }, + destroyOnHidden: { + type: Boolean, + default: true, + }, }, emits: ["update:expanded", "expand"], setup(props, { slots, emit, expose }) { @@ -163,6 +169,32 @@ export const XThink = defineComponent({ const prefixCls = computed(() => props.prefixCls); + // Content is kept mounted and only toggled with `v-show` when + // `destroyOnHidden` is false, so streaming output and inner component + // state survive a collapse. Keep `vShow` attached in both modes so changing + // `destroyOnHidden` at runtime does not reset the directive lifecycle. + function renderContent() { + const node = ( +
+
+ {slots.default?.()} +
+
+ ); + + return withDirectives(node, [[vShow, mergedExpanded.value]]); + } + expose({ get nativeElement() { return rootRef.value as HTMLDivElement; @@ -230,6 +262,7 @@ export const XThink = defineComponent({ {/* Collapsible content */} - {mergedExpanded.value ? ( -
-
- {slots.default?.()} -
-
- ) : null} + {props.destroyOnHidden && !mergedExpanded.value + ? null + : renderContent()}
); diff --git a/packages/x/components/think/__tests__/index.test.tsx b/packages/x/components/think/__tests__/index.test.tsx index 5ff28fd9..19bc46ba 100644 --- a/packages/x/components/think/__tests__/index.test.tsx +++ b/packages/x/components/think/__tests__/index.test.tsx @@ -206,6 +206,115 @@ describe("Think", () => { expect(chevron.attributes("style")).toContain("rotate(0deg)"); }); + it("destroys the content node on collapse by default", async () => { + const wrapper = mount(Think, { + slots: { default: () => "Toggle me" }, + }); + + expect(wrapper.find(".antd-think-content").exists()).toBe(true); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + + expect(wrapper.find(".antd-think-content").exists()).toBe(false); + }); + + it("keeps the content node mounted and toggles its visibility when destroyOnHidden is false", async () => { + const wrapper = mount(Think, { + props: { destroyOnHidden: false, defaultExpanded: false }, + slots: { default: () => "Hidden but mounted" }, + }); + + const content = wrapper.find(".antd-think-content"); + expect(content.exists()).toBe(true); + expect(wrapper.text()).toContain("Hidden but mounted"); + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + + expect( + content.element.parentElement?.getAttribute("style") ?? "", + ).not.toContain("display: none"); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + }); + + it("preserves inner state across collapse when destroyOnHidden is false", async () => { + const wrapper = mount(Think, { + props: { destroyOnHidden: false }, + slots: { default: () => h("input", { class: "inner-input" }) }, + }); + + const input = wrapper.find(".inner-input"); + expect(input.exists()).toBe(true); + const inputEl = input.element; + inputEl.value = "streaming"; + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + + const afterCollapse = wrapper.find(".inner-input"); + expect(afterCollapse.exists()).toBe(true); + // Same DOM node, so the value typed before collapsing survives. + expect(afterCollapse.element).toBe(inputEl); + expect(afterCollapse.element.value).toBe("streaming"); + }); + + it("keeps toggling after destroyOnHidden changes at runtime", async () => { + const wrapper = mount(Think, { + props: { destroyOnHidden: false }, + slots: { default: () => "Dynamic content" }, + }); + + await wrapper.setProps({ destroyOnHidden: true }); + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + expect(wrapper.find(".antd-think-content").exists()).toBe(false); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + expect(wrapper.find(".antd-think-content").exists()).toBe(true); + + await wrapper.setProps({ destroyOnHidden: false }); + const content = wrapper.find(".antd-think-content"); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + expect(content.exists()).toBe(true); + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + + await wrapper.find(".antd-think-status-wrapper").trigger("click"); + await nextTick(); + expect( + content.element.parentElement?.getAttribute("style") ?? "", + ).not.toContain("display: none"); + }); + + it("keeps the content node mounted for controlled expanded when destroyOnHidden is false", async () => { + const wrapper = mount(Think, { + props: { destroyOnHidden: false, expanded: true }, + slots: { default: () => "Controlled content" }, + }); + + expect(wrapper.find(".antd-think-content").exists()).toBe(true); + + await wrapper.setProps({ expanded: false }); + await nextTick(); + + expect(wrapper.find(".antd-think-content").exists()).toBe(true); + expect(wrapper.text()).toContain("Controlled content"); + }); + it("exposes nativeElement ref", () => { const wrapper = mount(Think, { slots: { default: () => "Content" }, diff --git a/packages/x/components/think/interface.ts b/packages/x/components/think/interface.ts index df326e2a..b2a6f6ff 100644 --- a/packages/x/components/think/interface.ts +++ b/packages/x/components/think/interface.ts @@ -79,6 +79,13 @@ export interface ThinkProps extends Omit { * @descZH 标题文字闪烁动画 */ blink?: boolean; + + /** + * @desc Whether to destroy the content node when collapsed + * @descZH 折叠时是否销毁内容节点 + * @default true + */ + destroyOnHidden?: boolean; } export interface ThinkRef { diff --git a/packages/x/components/thought-chain/Node.tsx b/packages/x/components/thought-chain/Node.tsx index d6e762b5..173397f3 100644 --- a/packages/x/components/thought-chain/Node.tsx +++ b/packages/x/components/thought-chain/Node.tsx @@ -2,7 +2,13 @@ import type { CSSProperties, PropType, VNodeChild } from "vue"; import { LeftOutlined, RightOutlined } from "@antdv-next/icons"; import { useConfig } from "antdv-next/config-provider/context"; -import { Transition, computed, defineComponent } from "vue"; +import { + Transition, + computed, + defineComponent, + vShow, + withDirectives, +} from "vue"; import type { SemanticType, @@ -261,6 +267,26 @@ export default defineComponent({ } as ThoughtChainFooterSlotInfo) : item.footer; const showContent = hasContent && (!collapsible || props.expanded); + const destroyOnHidden = item.destroyOnHidden ?? true; + + // Keep the content mounted and only toggle it with `v-show` when + // `destroyOnHidden` is false, so streaming output and inner component + // state survive a collapse. Keep `vShow` attached in both modes so + // changing `destroyOnHidden` at runtime does not reset its lifecycle. + const renderContent = () => { + const node = ( +
+
+
{contentNode}
+
+
+ ); + + return withDirectives(node, [[vShow, showContent]]); + }; return (
@@ -271,6 +297,7 @@ export default defineComponent({ {hasContent && ( - {showContent ? ( -
-
-
{contentNode}
-
-
- ) : null} + {destroyOnHidden && !showContent ? null : renderContent()}
)} diff --git a/packages/x/components/thought-chain/__tests__/index.test.tsx b/packages/x/components/thought-chain/__tests__/index.test.tsx index d9a9ce7b..836f4030 100644 --- a/packages/x/components/thought-chain/__tests__/index.test.tsx +++ b/packages/x/components/thought-chain/__tests__/index.test.tsx @@ -346,6 +346,149 @@ describe("ThoughtChain", () => { expect(wrapper.findAll(".antd-thought-chain-node").length).toBe(2); }); + it("keeps the item content mounted and toggles its visibility when destroyOnHidden is false", async () => { + const wrapper = mount(ThoughtChain, { + props: { + items: [ + { + key: "1", + title: "Step 1", + content: h("span", "Hidden but mounted"), + collapsible: true, + destroyOnHidden: false, + }, + ], + defaultExpandedKeys: [], + }, + }); + + const content = wrapper.find(".antd-thought-chain-node-content"); + expect(content.exists()).toBe(true); + expect(wrapper.text()).toContain("Hidden but mounted"); + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + + expect( + content.element.parentElement?.getAttribute("style") ?? "", + ).not.toContain("display: none"); + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + }); + + it("preserves item content across collapse when destroyOnHidden is false", async () => { + const wrapper = mount(ThoughtChain, { + props: { + items: [ + { + key: "1", + title: "Step 1", + content: h("input", { class: "inner-input" }), + collapsible: true, + destroyOnHidden: false, + }, + ], + defaultExpandedKeys: ["1"], + }, + }); + + const inputEl = wrapper.find(".inner-input").element; + inputEl.value = "streaming"; + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + + const afterCollapse = wrapper.find(".inner-input"); + expect(afterCollapse.exists()).toBe(true); + // Same DOM node, so the value typed before collapsing survives. + expect(afterCollapse.element).toBe(inputEl); + expect(afterCollapse.element.value).toBe("streaming"); + }); + + it("keeps toggling after destroyOnHidden changes at runtime", async () => { + const createItems = (destroyOnHidden: boolean) => [ + { + key: "1", + title: "Step 1", + content: h("span", "Dynamic content"), + collapsible: true, + destroyOnHidden, + }, + ]; + const wrapper = mount(ThoughtChain, { + props: { + items: createItems(false), + defaultExpandedKeys: ["1"], + }, + }); + + await wrapper.setProps({ items: createItems(true) }); + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + expect(wrapper.find(".antd-thought-chain-node-content").exists()).toBe( + false, + ); + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + expect(wrapper.find(".antd-thought-chain-node-content").exists()).toBe( + true, + ); + + await wrapper.setProps({ items: createItems(false) }); + const content = wrapper.find(".antd-thought-chain-node-content"); + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + expect(content.exists()).toBe(true); + expect(content.element.parentElement?.getAttribute("style")).toContain( + "display: none", + ); + + await wrapper.find(".antd-thought-chain-node-collapsible").trigger("click"); + await nextTick(); + expect( + content.element.parentElement?.getAttribute("style") ?? "", + ).not.toContain("display: none"); + }); + + it("keeps the item content mounted for controlled expandedKeys when destroyOnHidden is false", async () => { + const wrapper = mount(ThoughtChain, { + props: { + items: [ + { + key: "1", + title: "Step 1", + content: h("span", "Controlled content"), + collapsible: true, + destroyOnHidden: false, + }, + ], + expandedKeys: ["1"], + }, + }); + + expect(wrapper.find(".antd-thought-chain-node-content").exists()).toBe( + true, + ); + + await wrapper.setProps({ expandedKeys: [] }); + await nextTick(); + + expect(wrapper.find(".antd-thought-chain-node-content").exists()).toBe( + true, + ); + expect(wrapper.text()).toContain("Controlled content"); + }); + it("exposes nativeElement ref", () => { const wrapper = mount(ThoughtChain, { props: { items: basicItems }, diff --git a/packages/x/components/thought-chain/interface.ts b/packages/x/components/thought-chain/interface.ts index 7cdd7545..63ac09f4 100644 --- a/packages/x/components/thought-chain/interface.ts +++ b/packages/x/components/thought-chain/interface.ts @@ -100,6 +100,13 @@ export interface ThoughtChainItemType { * @descZH 标题闪烁动画 */ blink?: boolean; + + /** + * @desc Whether to destroy the content node when collapsed + * @descZH 折叠时是否销毁内容节点 + * @default true + */ + destroyOnHidden?: boolean; } export interface ThoughtChainProps extends Omit {