@assistant-ui/vue 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/primitives/ComposerPrimitiveInput.d.ts.map +1 -1
- package/dist/primitives/ComposerPrimitiveInput.js +1 -1
- package/dist/primitives/ComposerPrimitiveInput.js.map +1 -1
- package/dist/primitives/MessagePrimitiveParts.d.ts +1 -0
- package/dist/primitives/MessagePrimitiveParts.d.ts.map +1 -1
- package/dist/primitives/MessagePrimitiveParts.js.map +1 -1
- package/dist/primitives/ThreadPrimitiveMessages.d.ts +3 -5
- package/dist/primitives/ThreadPrimitiveMessages.d.ts.map +1 -1
- package/dist/primitives/ThreadPrimitiveMessages.js +3 -5
- package/dist/primitives/ThreadPrimitiveMessages.js.map +1 -1
- package/dist/primitives/ThreadPrimitiveViewport.d.ts.map +1 -1
- package/dist/primitives/ThreadPrimitiveViewport.js +21 -96
- package/dist/primitives/ThreadPrimitiveViewport.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/primitives-content.test.ts +80 -0
- package/src/__tests__/primitives.test.ts +47 -0
- package/src/primitives/ComposerPrimitiveInput.ts +7 -1
- package/src/primitives/MessagePrimitiveParts.ts +1 -0
- package/src/primitives/ThreadPrimitiveMessages.ts +3 -5
- package/src/primitives/ThreadPrimitiveViewport.ts +22 -135
- package/src/primitives/ThreadPrimitiveViewportFooter.test.ts +101 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ComposerPrimitiveInput.d.ts","sourceRoot":"","sources":["../../src/primitives/ComposerPrimitiveInput.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,KAAK,CAAC;AAQrE;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"ComposerPrimitiveInput.d.ts","sourceRoot":"","sources":["../../src/primitives/ComposerPrimitiveInput.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkC,KAAK,SAAS,EAAE,MAAM,KAAK,CAAC;AAQrE;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;0GAoDjC,CAAC"}
|
|
@@ -38,7 +38,7 @@ const ComposerPrimitiveInput = defineComponent({
|
|
|
38
38
|
};
|
|
39
39
|
const onKeydown = (event) => {
|
|
40
40
|
if (event.defaultPrevented || !props.submitOnEnter) return;
|
|
41
|
-
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
41
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing || event.keyCode === 229) return;
|
|
42
42
|
if (disabled.value || threadDisabled.value || isAttrDisabled(attrs)) return;
|
|
43
43
|
event.preventDefault();
|
|
44
44
|
send();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ComposerPrimitiveInput.js","names":[],"sources":["../../src/primitives/ComposerPrimitiveInput.ts"],"sourcesContent":["import { defineComponent, h, mergeProps, type SlotsType } from \"vue\";\nimport { flushTapSync } from \"@assistant-ui/tap\";\nimport { composerInputDisabled } from \"@assistant-ui/core/store/internal\";\nimport { isAttrDisabled } from \"./attrDisabled\";\nimport { useAui } from \"../useAui\";\nimport { useAuiState } from \"../useAuiState\";\nimport { useComposerSendState } from \"./useComposerSendState\";\n\n/**\n * A textarea bound to the composer text, disabled while the thread is\n * disabled. Enter submits (Shift+Enter inserts a newline, IME composition is\n * ignored) unless `submitOnEnter` is false; an Enter that cannot send falls\n * through to a newline, and a fallthrough keydown listener can veto the\n * submit with `preventDefault`. While the composer is not editing (an edit\n * composer before `beginEdit`), the value stays controlled to the empty\n * composer text.\n */\nexport const ComposerPrimitiveInput = defineComponent({\n name: \"ComposerPrimitiveInput\",\n inheritAttrs: false,\n props: {\n submitOnEnter: {\n type: Boolean,\n default: true,\n },\n },\n slots: Object as SlotsType<Record<string, never>>,\n setup(props, { attrs }) {\n const aui = useAui();\n const text = useAuiState((s) =>\n s.composer.isEditing ? s.composer.text : \"\",\n );\n const threadDisabled = useAuiState(composerInputDisabled);\n const { disabled, send } = useComposerSendState();\n\n const onInput = (event: Event) => {\n const target = event.target as HTMLTextAreaElement;\n if (!aui.composer.getState().isEditing) {\n target.value = text.value;\n return;\n }\n flushTapSync(() => aui.composer.setText(target.value));\n };\n const onKeydown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || !props.submitOnEnter) return;\n if (event.key !== \"Enter\"
|
|
1
|
+
{"version":3,"file":"ComposerPrimitiveInput.js","names":[],"sources":["../../src/primitives/ComposerPrimitiveInput.ts"],"sourcesContent":["import { defineComponent, h, mergeProps, type SlotsType } from \"vue\";\nimport { flushTapSync } from \"@assistant-ui/tap\";\nimport { composerInputDisabled } from \"@assistant-ui/core/store/internal\";\nimport { isAttrDisabled } from \"./attrDisabled\";\nimport { useAui } from \"../useAui\";\nimport { useAuiState } from \"../useAuiState\";\nimport { useComposerSendState } from \"./useComposerSendState\";\n\n/**\n * A textarea bound to the composer text, disabled while the thread is\n * disabled. Enter submits (Shift+Enter inserts a newline, IME composition is\n * ignored) unless `submitOnEnter` is false; an Enter that cannot send falls\n * through to a newline, and a fallthrough keydown listener can veto the\n * submit with `preventDefault`. While the composer is not editing (an edit\n * composer before `beginEdit`), the value stays controlled to the empty\n * composer text.\n */\nexport const ComposerPrimitiveInput = defineComponent({\n name: \"ComposerPrimitiveInput\",\n inheritAttrs: false,\n props: {\n submitOnEnter: {\n type: Boolean,\n default: true,\n },\n },\n slots: Object as SlotsType<Record<string, never>>,\n setup(props, { attrs }) {\n const aui = useAui();\n const text = useAuiState((s) =>\n s.composer.isEditing ? s.composer.text : \"\",\n );\n const threadDisabled = useAuiState(composerInputDisabled);\n const { disabled, send } = useComposerSendState();\n\n const onInput = (event: Event) => {\n const target = event.target as HTMLTextAreaElement;\n if (!aui.composer.getState().isEditing) {\n target.value = text.value;\n return;\n }\n flushTapSync(() => aui.composer.setText(target.value));\n };\n const onKeydown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || !props.submitOnEnter) return;\n if (\n event.key !== \"Enter\" ||\n event.shiftKey ||\n event.isComposing ||\n event.keyCode === 229\n )\n return;\n if (disabled.value || threadDisabled.value || isAttrDisabled(attrs))\n return;\n event.preventDefault();\n send();\n };\n\n return () =>\n h(\n \"textarea\",\n mergeProps({ name: \"input\" }, attrs, {\n value: text.value,\n disabled: threadDisabled.value || isAttrDisabled(attrs),\n onInput,\n onKeydown,\n }),\n );\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;AAiBA,MAAa,yBAAyB,gBAAgB;CACpD,MAAM;CACN,cAAc;CACd,OAAO,EACL,eAAe;EACb,MAAM;EACN,SAAS;CACX,EACF;CACA,OAAO;CACP,MAAM,OAAO,EAAE,SAAS;EACtB,MAAM,MAAM,OAAO;EACnB,MAAM,OAAO,aAAa,MACxB,EAAE,SAAS,YAAY,EAAE,SAAS,OAAO,EAC3C;EACA,MAAM,iBAAiB,YAAY,qBAAqB;EACxD,MAAM,EAAE,UAAU,SAAS,qBAAqB;EAEhD,MAAM,WAAW,UAAiB;GAChC,MAAM,SAAS,MAAM;GACrB,IAAI,CAAC,IAAI,SAAS,SAAS,CAAC,CAAC,WAAW;IACtC,OAAO,QAAQ,KAAK;IACpB;GACF;GACA,mBAAmB,IAAI,SAAS,QAAQ,OAAO,KAAK,CAAC;EACvD;EACA,MAAM,aAAa,UAAyB;GAC1C,IAAI,MAAM,oBAAoB,CAAC,MAAM,eAAe;GACpD,IACE,MAAM,QAAQ,WACd,MAAM,YACN,MAAM,eACN,MAAM,YAAY,KAElB;GACF,IAAI,SAAS,SAAS,eAAe,SAAS,eAAe,KAAK,GAChE;GACF,MAAM,eAAe;GACrB,KAAK;EACP;EAEA,aACE,EACE,YACA,WAAW,EAAE,MAAM,QAAQ,GAAG,OAAO;GACnC,OAAO,KAAK;GACZ,UAAU,eAAe,SAAS,eAAe,KAAK;GACtD;GACA;EACF,CAAC,CACH;CACJ;AACF,CAAC"}
|
|
@@ -23,6 +23,7 @@ export type ToolUIProps = {
|
|
|
23
23
|
addResult: PartMethods["addToolResult"];
|
|
24
24
|
resume: PartMethods["resumeToolCall"];
|
|
25
25
|
respondToApproval: PartMethods["respondToToolApproval"];
|
|
26
|
+
/** @deprecated Experimental since 2026-09-23. Not scheduled for removal; the API may change in any release. */
|
|
26
27
|
unstable_recordInteraction?: PartMethods["unstable_recordInteraction"];
|
|
27
28
|
};
|
|
28
29
|
/** The value of the single `data` prop passed to a Vue data renderer. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessagePrimitiveParts.d.ts","sourceRoot":"","sources":["../../src/primitives/MessagePrimitiveParts.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,UAAU,EAChB,MAAM,KAAK,CAAC;AAGb,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAQjE,eAAO,MAAM,2BAA2B,YAA4B,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IAC7D,SAAS,EAAE,WAAW,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IACtC,iBAAiB,EAAE,WAAW,CAAC,uBAAuB,CAAC,CAAC;IACxD,0BAA0B,CAAC,EAAE,WAAW,CAAC,4BAA4B,CAAC,CAAC;CACxE,CAAC;AAEF,yEAAyE;AACzE,MAAM,MAAM,WAAW,CAAC,CAAC,GAAG,OAAO,IAAI;IACrC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,MAAM,CAAC,GAAG;QACtE,IAAI,EAAE,CAAC,CAAC;KACT,CAAC;CACH,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB;;oMAEiB,UAAU,EAAE,sFAmG7D,CAAC"}
|
|
1
|
+
{"version":3,"file":"MessagePrimitiveParts.d.ts","sourceRoot":"","sources":["../../src/primitives/MessagePrimitiveParts.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,UAAU,EAChB,MAAM,KAAK,CAAC;AAGb,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAQjE,eAAO,MAAM,2BAA2B,YAA4B,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IAC7D,SAAS,EAAE,WAAW,CAAC,eAAe,CAAC,CAAC;IACxC,MAAM,EAAE,WAAW,CAAC,gBAAgB,CAAC,CAAC;IACtC,iBAAiB,EAAE,WAAW,CAAC,uBAAuB,CAAC,CAAC;IACxD,+GAA+G;IAC/G,0BAA0B,CAAC,EAAE,WAAW,CAAC,4BAA4B,CAAC,CAAC;CACxE,CAAC;AAEF,yEAAyE;AACzE,MAAM,MAAM,WAAW,CAAC,CAAC,GAAG,OAAO,IAAI;IACrC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,MAAM,CAAC,GAAG;QACtE,IAAI,EAAE,CAAC,CAAC;KACT,CAAC;CACH,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB;;oMAEiB,UAAU,EAAE,sFAmG7D,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MessagePrimitiveParts.js","names":[],"sources":["../../src/primitives/MessagePrimitiveParts.ts"],"sourcesContent":["import {\n defineComponent,\n h,\n type Component,\n type SlotsType,\n type VNodeChild,\n} from \"vue\";\nimport { isMcpAppUri } from \"@assistant-ui/core\";\nimport { getMessagePartKeys } from \"@assistant-ui/core/internal\";\nimport {\n resolveToolCallText,\n type PartMethods,\n} from \"@assistant-ui/core/store\";\nimport { isDevelopment } from \"@assistant-ui/core/store/internal\";\nimport type { AssistantState } from \"@assistant-ui/store/client\";\nimport { useAui } from \"../useAui\";\nimport { useAuiState } from \"../useAuiState\";\nimport { PartByIndexProvider } from \"./PartByIndexProvider\";\nimport { useStableKeys } from \"./stableKeys\";\n\nconst warnedTypes = new Set<string>();\n\nexport const clearPartWarningsForTesting = () => warnedTypes.clear();\n\n/**\n * The value of the single `tool` prop passed to a tool UI registered in the\n * `tools` scope. React spreads the part state as individual props; in Vue any\n * prop the component does not declare falls through to `$attrs` and gets\n * stringified onto its root element, so the Vue face passes one `tool` object\n * instead, which a component always declares in full. The tools scope\n * declares renderers as React's `ToolCallMessagePartComponent`, so the Vue\n * face bridges the seam with casts at registration and render; a genuine\n * React renderer arriving through a shared toolkit type-checks at `setToolUI`\n * and then fails when invoked as a Vue component. This type is the Vue-facing\n * contract for custom components. Toolkit `renderText` descriptors resolve\n * through the neutral resolver and render string or number results directly;\n * a react-element-valued descriptor is react-only and renders nothing here.\n */\nexport type ToolUIProps = {\n part: Extract<AssistantState[\"part\"], { type: \"tool-call\" }>;\n addResult: PartMethods[\"addToolResult\"];\n resume: PartMethods[\"resumeToolCall\"];\n respondToApproval: PartMethods[\"respondToToolApproval\"];\n unstable_recordInteraction?: PartMethods[\"unstable_recordInteraction\"];\n};\n\n/** The value of the single `data` prop passed to a Vue data renderer. */\nexport type DataUIProps<T = unknown> = {\n part: Omit<Extract<AssistantState[\"part\"], { type: \"data\" }>, \"data\"> & {\n data: T;\n };\n};\n\n/**\n * Renders the current message's content parts in order, each scoped through\n * {@link PartByIndexProvider}. A `tool-call` part first resolves a renderer\n * registered in the `tools` scope by tool name (`aui.tools.setToolUI`, or a\n * `Tools({ toolkit })` config entry) and renders it with a single `tool` prop\n * of type {@link ToolUIProps}.\n * Otherwise a slot named after the part type (`text`, `reasoning`,\n * `tool-call`, ...) renders that part; the `default` slot is the fallback for\n * types without a named slot, except text, which always renders its text\n * unless a `text` slot overrides it.\n */\nexport const MessagePrimitiveParts = defineComponent({\n name: \"MessagePrimitiveParts\",\n slots: Object as SlotsType<Record<string, (() => VNodeChild[]) | undefined>>,\n setup(_, { slots }) {\n const parts = useAuiState((s) => s.message.parts);\n const partKeys = useStableKeys(() => getMessagePartKeys(parts.value));\n const PartView = defineComponent({\n name: \"MessagePartView\",\n setup() {\n const aui = useAui();\n const type = useAuiState((s) => s.part.type);\n const text = useAuiState((s) =>\n s.part.type === \"text\" ? s.part.text : \"\",\n );\n const toolUI = useAuiState((s) => {\n if (s.part.type !== \"tool-call\") return null;\n return s.optional.tools?.toolUIs[s.part.toolName]?.[0] ?? null;\n });\n const mcpAppRender = useAuiState((s) =>\n s.part.type === \"tool-call\" &&\n isMcpAppUri(s.part.mcp?.app?.resourceUri)\n ? (s.optional.tools?.mcpApp?.render ?? null)\n : null,\n );\n const toolPart = useAuiState((s) =>\n s.part.type === \"tool-call\" ? s.part : null,\n );\n const dataRenderer = useAuiState((s) => {\n if (s.part.type !== \"data\") return null;\n const named =\n s.optional.dataRenderers?.renderers[s.part.name]?.[0] ?? null;\n return named ?? s.optional.dataRenderers?.fallbacks[0] ?? null;\n });\n const dataPart = useAuiState((s) =>\n s.part.type === \"data\" ? s.part : null,\n );\n return () => {\n if (type.value === \"tool-call\") {\n const registration = toolUI.value;\n const part = toolPart.value;\n if (registration?.renderText && part) {\n const resolved = resolveToolCallText(\n registration.renderText,\n part,\n );\n if (\n typeof resolved === \"string\" ||\n typeof resolved === \"number\"\n ) {\n return resolved;\n }\n return null;\n }\n const Render = registration?.render ?? mcpAppRender.value;\n if (Render && part) {\n return h(Render as unknown as Component, {\n tool: {\n part,\n addResult: aui.part.addToolResult,\n resume: aui.part.resumeToolCall,\n respondToApproval: aui.part.respondToToolApproval,\n ...(aui.part.unstable_recordInteraction && {\n unstable_recordInteraction:\n aui.part.unstable_recordInteraction,\n }),\n } satisfies ToolUIProps,\n });\n }\n }\n if (type.value === \"data\") {\n const Render = dataRenderer.value;\n const part = dataPart.value;\n if (Render && part) {\n return h(Render as unknown as Component, {\n data: { part } satisfies DataUIProps,\n });\n }\n }\n if (type.value === \"text\") {\n const slot = slots.text;\n return slot\n ? slot()\n : h(\"p\", { style: { whiteSpace: \"pre-line\" } }, text.value);\n }\n const slot = slots[type.value] ?? slots.default;\n if (slot) return slot();\n if (isDevelopment && !warnedTypes.has(type.value)) {\n warnedTypes.add(type.value);\n console.warn(\n `MessagePrimitiveParts: no slot for part type \"${type.value}\"; the part renders nothing. Add a #${type.value} or #default slot.`,\n );\n }\n return null;\n };\n },\n });\n return () =>\n partKeys.value.map((key, index) =>\n h(PartByIndexProvider, { index, key }, { default: () => h(PartView) }),\n );\n },\n});\n"],"mappings":";;;;;;;;;;AAoBA,MAAM,8BAAc,IAAI,IAAY;AAEpC,MAAa,oCAAoC,YAAY,MAAM;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"MessagePrimitiveParts.js","names":[],"sources":["../../src/primitives/MessagePrimitiveParts.ts"],"sourcesContent":["import {\n defineComponent,\n h,\n type Component,\n type SlotsType,\n type VNodeChild,\n} from \"vue\";\nimport { isMcpAppUri } from \"@assistant-ui/core\";\nimport { getMessagePartKeys } from \"@assistant-ui/core/internal\";\nimport {\n resolveToolCallText,\n type PartMethods,\n} from \"@assistant-ui/core/store\";\nimport { isDevelopment } from \"@assistant-ui/core/store/internal\";\nimport type { AssistantState } from \"@assistant-ui/store/client\";\nimport { useAui } from \"../useAui\";\nimport { useAuiState } from \"../useAuiState\";\nimport { PartByIndexProvider } from \"./PartByIndexProvider\";\nimport { useStableKeys } from \"./stableKeys\";\n\nconst warnedTypes = new Set<string>();\n\nexport const clearPartWarningsForTesting = () => warnedTypes.clear();\n\n/**\n * The value of the single `tool` prop passed to a tool UI registered in the\n * `tools` scope. React spreads the part state as individual props; in Vue any\n * prop the component does not declare falls through to `$attrs` and gets\n * stringified onto its root element, so the Vue face passes one `tool` object\n * instead, which a component always declares in full. The tools scope\n * declares renderers as React's `ToolCallMessagePartComponent`, so the Vue\n * face bridges the seam with casts at registration and render; a genuine\n * React renderer arriving through a shared toolkit type-checks at `setToolUI`\n * and then fails when invoked as a Vue component. This type is the Vue-facing\n * contract for custom components. Toolkit `renderText` descriptors resolve\n * through the neutral resolver and render string or number results directly;\n * a react-element-valued descriptor is react-only and renders nothing here.\n */\nexport type ToolUIProps = {\n part: Extract<AssistantState[\"part\"], { type: \"tool-call\" }>;\n addResult: PartMethods[\"addToolResult\"];\n resume: PartMethods[\"resumeToolCall\"];\n respondToApproval: PartMethods[\"respondToToolApproval\"];\n /** @deprecated Experimental since 2026-09-23. Not scheduled for removal; the API may change in any release. */\n unstable_recordInteraction?: PartMethods[\"unstable_recordInteraction\"];\n};\n\n/** The value of the single `data` prop passed to a Vue data renderer. */\nexport type DataUIProps<T = unknown> = {\n part: Omit<Extract<AssistantState[\"part\"], { type: \"data\" }>, \"data\"> & {\n data: T;\n };\n};\n\n/**\n * Renders the current message's content parts in order, each scoped through\n * {@link PartByIndexProvider}. A `tool-call` part first resolves a renderer\n * registered in the `tools` scope by tool name (`aui.tools.setToolUI`, or a\n * `Tools({ toolkit })` config entry) and renders it with a single `tool` prop\n * of type {@link ToolUIProps}.\n * Otherwise a slot named after the part type (`text`, `reasoning`,\n * `tool-call`, ...) renders that part; the `default` slot is the fallback for\n * types without a named slot, except text, which always renders its text\n * unless a `text` slot overrides it.\n */\nexport const MessagePrimitiveParts = defineComponent({\n name: \"MessagePrimitiveParts\",\n slots: Object as SlotsType<Record<string, (() => VNodeChild[]) | undefined>>,\n setup(_, { slots }) {\n const parts = useAuiState((s) => s.message.parts);\n const partKeys = useStableKeys(() => getMessagePartKeys(parts.value));\n const PartView = defineComponent({\n name: \"MessagePartView\",\n setup() {\n const aui = useAui();\n const type = useAuiState((s) => s.part.type);\n const text = useAuiState((s) =>\n s.part.type === \"text\" ? s.part.text : \"\",\n );\n const toolUI = useAuiState((s) => {\n if (s.part.type !== \"tool-call\") return null;\n return s.optional.tools?.toolUIs[s.part.toolName]?.[0] ?? null;\n });\n const mcpAppRender = useAuiState((s) =>\n s.part.type === \"tool-call\" &&\n isMcpAppUri(s.part.mcp?.app?.resourceUri)\n ? (s.optional.tools?.mcpApp?.render ?? null)\n : null,\n );\n const toolPart = useAuiState((s) =>\n s.part.type === \"tool-call\" ? s.part : null,\n );\n const dataRenderer = useAuiState((s) => {\n if (s.part.type !== \"data\") return null;\n const named =\n s.optional.dataRenderers?.renderers[s.part.name]?.[0] ?? null;\n return named ?? s.optional.dataRenderers?.fallbacks[0] ?? null;\n });\n const dataPart = useAuiState((s) =>\n s.part.type === \"data\" ? s.part : null,\n );\n return () => {\n if (type.value === \"tool-call\") {\n const registration = toolUI.value;\n const part = toolPart.value;\n if (registration?.renderText && part) {\n const resolved = resolveToolCallText(\n registration.renderText,\n part,\n );\n if (\n typeof resolved === \"string\" ||\n typeof resolved === \"number\"\n ) {\n return resolved;\n }\n return null;\n }\n const Render = registration?.render ?? mcpAppRender.value;\n if (Render && part) {\n return h(Render as unknown as Component, {\n tool: {\n part,\n addResult: aui.part.addToolResult,\n resume: aui.part.resumeToolCall,\n respondToApproval: aui.part.respondToToolApproval,\n ...(aui.part.unstable_recordInteraction && {\n unstable_recordInteraction:\n aui.part.unstable_recordInteraction,\n }),\n } satisfies ToolUIProps,\n });\n }\n }\n if (type.value === \"data\") {\n const Render = dataRenderer.value;\n const part = dataPart.value;\n if (Render && part) {\n return h(Render as unknown as Component, {\n data: { part } satisfies DataUIProps,\n });\n }\n }\n if (type.value === \"text\") {\n const slot = slots.text;\n return slot\n ? slot()\n : h(\"p\", { style: { whiteSpace: \"pre-line\" } }, text.value);\n }\n const slot = slots[type.value] ?? slots.default;\n if (slot) return slot();\n if (isDevelopment && !warnedTypes.has(type.value)) {\n warnedTypes.add(type.value);\n console.warn(\n `MessagePrimitiveParts: no slot for part type \"${type.value}\"; the part renders nothing. Add a #${type.value} or #default slot.`,\n );\n }\n return null;\n };\n },\n });\n return () =>\n partKeys.value.map((key, index) =>\n h(PartByIndexProvider, { index, key }, { default: () => h(PartView) }),\n );\n },\n});\n"],"mappings":";;;;;;;;;;AAoBA,MAAM,8BAAc,IAAI,IAAY;AAEpC,MAAa,oCAAoC,YAAY,MAAM;;;;;;;;;;;;AA2CnE,MAAa,wBAAwB,gBAAgB;CACnD,MAAM;CACN,OAAO;CACP,MAAM,GAAG,EAAE,SAAS;EAClB,MAAM,QAAQ,aAAa,MAAM,EAAE,QAAQ,KAAK;EAChD,MAAM,WAAW,oBAAoB,mBAAmB,MAAM,KAAK,CAAC;EACpE,MAAM,WAAW,gBAAgB;GAC/B,MAAM;GACN,QAAQ;IACN,MAAM,MAAM,OAAO;IACnB,MAAM,OAAO,aAAa,MAAM,EAAE,KAAK,IAAI;IAC3C,MAAM,OAAO,aAAa,MACxB,EAAE,KAAK,SAAS,SAAS,EAAE,KAAK,OAAO,EACzC;IACA,MAAM,SAAS,aAAa,MAAM;KAChC,IAAI,EAAE,KAAK,SAAS,aAAa,OAAO;KACxC,OAAO,EAAE,SAAS,OAAO,QAAQ,EAAE,KAAK,SAAS,GAAG,MAAM;IAC5D,CAAC;IACD,MAAM,eAAe,aAAa,MAChC,EAAE,KAAK,SAAS,eAChB,YAAY,EAAE,KAAK,KAAK,KAAK,WAAW,IACnC,EAAE,SAAS,OAAO,QAAQ,UAAU,OACrC,IACN;IACA,MAAM,WAAW,aAAa,MAC5B,EAAE,KAAK,SAAS,cAAc,EAAE,OAAO,IACzC;IACA,MAAM,eAAe,aAAa,MAAM;KACtC,IAAI,EAAE,KAAK,SAAS,QAAQ,OAAO;KAGnC,OADE,EAAE,SAAS,eAAe,UAAU,EAAE,KAAK,KAAK,GAAG,MAAM,QAC3C,EAAE,SAAS,eAAe,UAAU,MAAM;IAC5D,CAAC;IACD,MAAM,WAAW,aAAa,MAC5B,EAAE,KAAK,SAAS,SAAS,EAAE,OAAO,IACpC;IACA,aAAa;KACX,IAAI,KAAK,UAAU,aAAa;MAC9B,MAAM,eAAe,OAAO;MAC5B,MAAM,OAAO,SAAS;MACtB,IAAI,cAAc,cAAc,MAAM;OACpC,MAAM,WAAW,oBACf,aAAa,YACb,IACF;OACA,IACE,OAAO,aAAa,YACpB,OAAO,aAAa,UAEpB,OAAO;OAET,OAAO;MACT;MACA,MAAM,SAAS,cAAc,UAAU,aAAa;MACpD,IAAI,UAAU,MACZ,OAAO,EAAE,QAAgC,EACvC,MAAM;OACJ;OACA,WAAW,IAAI,KAAK;OACpB,QAAQ,IAAI,KAAK;OACjB,mBAAmB,IAAI,KAAK;OAC5B,GAAI,IAAI,KAAK,8BAA8B,EACzC,4BACE,IAAI,KAAK,2BACb;MACF,EACF,CAAC;KAEL;KACA,IAAI,KAAK,UAAU,QAAQ;MACzB,MAAM,SAAS,aAAa;MAC5B,MAAM,OAAO,SAAS;MACtB,IAAI,UAAU,MACZ,OAAO,EAAE,QAAgC,EACvC,MAAM,EAAE,KAAK,EACf,CAAC;KAEL;KACA,IAAI,KAAK,UAAU,QAAQ;MACzB,MAAM,OAAO,MAAM;MACnB,OAAO,OACH,KAAK,IACL,EAAE,KAAK,EAAE,OAAO,EAAE,YAAY,WAAW,EAAE,GAAG,KAAK,KAAK;KAC9D;KACA,MAAM,OAAO,MAAM,KAAK,UAAU,MAAM;KACxC,IAAI,MAAM,OAAO,KAAK;KACtB,IAAI,iBAAiB,CAAC,YAAY,IAAI,KAAK,KAAK,GAAG;MACjD,YAAY,IAAI,KAAK,KAAK;MAC1B,QAAQ,KACN,iDAAiD,KAAK,MAAM,sCAAsC,KAAK,MAAM,mBAC/G;KACF;KACA,OAAO;IACT;GACF;EACF,CAAC;EACD,aACE,SAAS,MAAM,KAAK,KAAK,UACvB,EAAE,qBAAqB;GAAE;GAAO;EAAI,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,CAAC,CACvE;CACJ;AACF,CAAC"}
|
|
@@ -2,11 +2,9 @@ import { type SlotsType, type VNodeChild } from "vue";
|
|
|
2
2
|
/**
|
|
3
3
|
* Renders the default slot once per message in the current thread, each
|
|
4
4
|
* instance scoped to its message through {@link MessageByIdProvider} and
|
|
5
|
-
* keyed by the message id
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* response is its own identity, so the arrival of the real assistant message
|
|
9
|
-
* remounts that one row (a leave/enter pair under `<TransitionGroup>`).
|
|
5
|
+
* keyed by the message id. A stable id keeps its row component instance across
|
|
6
|
+
* updates; a new id remounts the row. Because rows render inside this
|
|
7
|
+
* component, an outer `<TransitionGroup>` cannot animate individual messages.
|
|
10
8
|
*
|
|
11
9
|
* @example
|
|
12
10
|
* ```html
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreadPrimitiveMessages.d.ts","sourceRoot":"","sources":["../../src/primitives/ThreadPrimitiveMessages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,KAAK,CAAC;AAM1E
|
|
1
|
+
{"version":3,"file":"ThreadPrimitiveMessages.d.ts","sourceRoot":"","sources":["../../src/primitives/ThreadPrimitiveMessages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,KAAK,CAAC;AAM1E;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB;;;cAEK,MAAM,UAAU,EAAE;yEAezD,CAAC"}
|
|
@@ -6,11 +6,9 @@ import { defineComponent, h } from "vue";
|
|
|
6
6
|
/**
|
|
7
7
|
* Renders the default slot once per message in the current thread, each
|
|
8
8
|
* instance scoped to its message through {@link MessageByIdProvider} and
|
|
9
|
-
* keyed by the message id
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* response is its own identity, so the arrival of the real assistant message
|
|
13
|
-
* remounts that one row (a leave/enter pair under `<TransitionGroup>`).
|
|
9
|
+
* keyed by the message id. A stable id keeps its row component instance across
|
|
10
|
+
* updates; a new id remounts the row. Because rows render inside this
|
|
11
|
+
* component, an outer `<TransitionGroup>` cannot animate individual messages.
|
|
14
12
|
*
|
|
15
13
|
* @example
|
|
16
14
|
* ```html
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreadPrimitiveMessages.js","names":[],"sources":["../../src/primitives/ThreadPrimitiveMessages.ts"],"sourcesContent":["import { defineComponent, h, type SlotsType, type VNodeChild } from \"vue\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { useAuiState } from \"../useAuiState\";\nimport { MessageByIdProvider } from \"./MessageByIdProvider\";\nimport { useStableKeys } from \"./stableKeys\";\n\n/**\n * Renders the default slot once per message in the current thread, each\n * instance scoped to its message through {@link MessageByIdProvider} and\n * keyed by the message id
|
|
1
|
+
{"version":3,"file":"ThreadPrimitiveMessages.js","names":[],"sources":["../../src/primitives/ThreadPrimitiveMessages.ts"],"sourcesContent":["import { defineComponent, h, type SlotsType, type VNodeChild } from \"vue\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { useAuiState } from \"../useAuiState\";\nimport { MessageByIdProvider } from \"./MessageByIdProvider\";\nimport { useStableKeys } from \"./stableKeys\";\n\n/**\n * Renders the default slot once per message in the current thread, each\n * instance scoped to its message through {@link MessageByIdProvider} and\n * keyed by the message id. A stable id keeps its row component instance across\n * updates; a new id remounts the row. Because rows render inside this\n * component, an outer `<TransitionGroup>` cannot animate individual messages.\n *\n * @example\n * ```html\n * <ThreadPrimitiveMessages>\n * <ChatMessage />\n * </ThreadPrimitiveMessages>\n * ```\n */\nexport const ThreadPrimitiveMessages = defineComponent({\n name: \"ThreadPrimitiveMessages\",\n slots: Object as SlotsType<{ default?: () => VNodeChild[] }>,\n setup(_, { slots }) {\n const messages = useAuiState((s) => s.thread.messages);\n const ids = useStableKeys(() =>\n messages.value.map((message) => message.id),\n );\n return () =>\n ids.value.map((id) =>\n h(\n MessageByIdProvider,\n { id, key: id },\n { default: () => slots.default?.() },\n ),\n );\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAoBA,MAAa,0BAA0B,gBAAgB;CACrD,MAAM;CACN,OAAO;CACP,MAAM,GAAG,EAAE,SAAS;EAClB,MAAM,WAAW,aAAa,MAAM,EAAE,OAAO,QAAQ;EACrD,MAAM,MAAM,oBACV,SAAS,MAAM,KAAK,YAAY,QAAQ,EAAE,CAC5C;EACA,aACE,IAAI,MAAM,KAAK,OACb,EACE,qBACA;GAAE;GAAI,KAAK;EAAG,GACd,EAAE,eAAe,MAAM,UAAU,EAAE,CACrC,CACF;CACJ;AACF,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreadPrimitiveViewport.d.ts","sourceRoot":"","sources":["../../src/primitives/ThreadPrimitiveViewport.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,SAAS,EACd,KAAK,UAAU,EAChB,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"ThreadPrimitiveViewport.d.ts","sourceRoot":"","sources":["../../src/primitives/ThreadPrimitiveViewport.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,SAAS,EACd,KAAK,UAAU,EAChB,MAAM,KAAK,CAAC;AAOb;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBK,MAAM,UAAU,EAAE;yEAuEzD,CAAC"}
|
|
@@ -2,7 +2,7 @@ import { useAuiState } from "../useAuiState.js";
|
|
|
2
2
|
import { useAuiEvent } from "../useAuiEvent.js";
|
|
3
3
|
import { viewportInjectionKey } from "./viewportContext.js";
|
|
4
4
|
import { defineComponent, h, onMounted, onScopeDispose, provide, shallowRef, watch } from "vue";
|
|
5
|
-
import {
|
|
5
|
+
import { createThreadViewportAutoScroll } from "@assistant-ui/store/client";
|
|
6
6
|
//#region src/primitives/ThreadPrimitiveViewport.ts
|
|
7
7
|
/**
|
|
8
8
|
* A scrollable container that keeps the thread pinned to the bottom: content
|
|
@@ -38,60 +38,23 @@ const ThreadPrimitiveViewport = defineComponent({
|
|
|
38
38
|
slots: Object,
|
|
39
39
|
setup(props, { slots }) {
|
|
40
40
|
const divRef = shallowRef(null);
|
|
41
|
-
const contentInset = shallowRef(0);
|
|
42
41
|
const contentInsetEntries = /* @__PURE__ */ new Map();
|
|
43
|
-
let intent = null;
|
|
44
42
|
const isAtBottom = shallowRef(true);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
div.scrollTo?.({
|
|
55
|
-
top: div.scrollHeight,
|
|
56
|
-
behavior
|
|
57
|
-
});
|
|
58
|
-
};
|
|
59
|
-
const scheduleScrollToBottom = (behavior) => {
|
|
60
|
-
intent = behavior;
|
|
61
|
-
if (typeof requestAnimationFrame === "undefined") return;
|
|
62
|
-
if (frame !== null) cancelAnimationFrame(frame);
|
|
63
|
-
frame = requestAnimationFrame(() => {
|
|
64
|
-
frame = null;
|
|
65
|
-
scrollToBottom(behavior);
|
|
66
|
-
});
|
|
67
|
-
};
|
|
68
|
-
const handleScroll = () => {
|
|
69
|
-
const div = divRef.value;
|
|
70
|
-
if (!div) return;
|
|
71
|
-
const newIsAtBottom = isViewportAtBottom(div, contentInset.value);
|
|
72
|
-
if (!(!newIsAtBottom && lastScrollTop < div.scrollTop)) {
|
|
73
|
-
if (newIsAtBottom) {
|
|
74
|
-
if (viewportOverflows(div, contentInset.value)) intent = null;
|
|
75
|
-
} else if (isUserScrollUp({
|
|
76
|
-
scrollTop: lastScrollTop,
|
|
77
|
-
scrollHeight: lastScrollHeight
|
|
78
|
-
}, div)) intent = null;
|
|
79
|
-
if (newIsAtBottom || intent === null) isAtBottom.value = newIsAtBottom;
|
|
43
|
+
const autoScroll = createThreadViewportAutoScroll({
|
|
44
|
+
getOptions: () => ({
|
|
45
|
+
autoScroll: props.autoScroll,
|
|
46
|
+
scrollToBottomOnInitialize: props.scrollToBottomOnInitialize,
|
|
47
|
+
scrollToBottomOnRunStart: props.scrollToBottomOnRunStart,
|
|
48
|
+
scrollToBottomOnThreadSwitch: props.scrollToBottomOnThreadSwitch
|
|
49
|
+
}),
|
|
50
|
+
onAtBottomChange: (value) => {
|
|
51
|
+
isAtBottom.value = value;
|
|
80
52
|
}
|
|
81
|
-
|
|
82
|
-
lastScrollHeight = div.scrollHeight;
|
|
83
|
-
};
|
|
53
|
+
});
|
|
84
54
|
const updateContentInset = () => {
|
|
85
55
|
let total = 0;
|
|
86
56
|
for (const height of contentInsetEntries.values()) total += height;
|
|
87
|
-
|
|
88
|
-
const grew = total > contentInset.value;
|
|
89
|
-
contentInset.value = total;
|
|
90
|
-
if (grew) {
|
|
91
|
-
if (intent) scrollToBottom(intent);
|
|
92
|
-
else if (props.autoScroll && isAtBottom.value) scrollToBottom("instant");
|
|
93
|
-
}
|
|
94
|
-
handleScroll();
|
|
57
|
+
autoScroll.setContentInset(total);
|
|
95
58
|
};
|
|
96
59
|
const registerContentInset = () => {
|
|
97
60
|
const id = Symbol();
|
|
@@ -108,64 +71,26 @@ const ThreadPrimitiveViewport = defineComponent({
|
|
|
108
71
|
}
|
|
109
72
|
};
|
|
110
73
|
};
|
|
111
|
-
|
|
112
|
-
const div = divRef.value;
|
|
113
|
-
if (!div) return;
|
|
114
|
-
const { scrollHeight, clientHeight } = div;
|
|
115
|
-
if (scrollHeight === lastObservedScrollHeight && clientHeight === lastObservedClientHeight) return;
|
|
116
|
-
lastObservedScrollHeight = scrollHeight;
|
|
117
|
-
lastObservedClientHeight = clientHeight;
|
|
118
|
-
if (intent) scrollToBottom(intent);
|
|
119
|
-
else if (props.autoScroll && isAtBottom.value) scrollToBottom("instant");
|
|
120
|
-
handleScroll();
|
|
121
|
-
};
|
|
122
|
-
const onPointerdown = () => {
|
|
123
|
-
intent = null;
|
|
124
|
-
if (frame !== null) {
|
|
125
|
-
cancelAnimationFrame(frame);
|
|
126
|
-
frame = null;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
let disconnect;
|
|
74
|
+
let detach;
|
|
130
75
|
onMounted(() => {
|
|
131
76
|
const div = divRef.value;
|
|
132
77
|
if (!div) return;
|
|
133
|
-
|
|
78
|
+
detach = autoScroll.attach(div);
|
|
134
79
|
});
|
|
135
80
|
onScopeDispose(() => {
|
|
136
|
-
|
|
137
|
-
|
|
81
|
+
detach?.();
|
|
82
|
+
autoScroll.dispose();
|
|
138
83
|
});
|
|
139
84
|
const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
initialized = false;
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (!enabled || initialized) return;
|
|
147
|
-
initialized = true;
|
|
148
|
-
if (intent !== null) return;
|
|
149
|
-
scheduleScrollToBottom("instant");
|
|
150
|
-
}, { immediate: true });
|
|
151
|
-
useAuiEvent("thread.runStart", () => {
|
|
152
|
-
if (!props.scrollToBottomOnRunStart) return;
|
|
153
|
-
scheduleScrollToBottom("auto");
|
|
154
|
-
});
|
|
155
|
-
useAuiEvent("threads.selectionChanged", () => {
|
|
156
|
-
if (!props.scrollToBottomOnThreadSwitch) return;
|
|
157
|
-
scheduleScrollToBottom("instant");
|
|
158
|
-
});
|
|
85
|
+
watch([hasMessages, () => props.scrollToBottomOnInitialize], ([has]) => autoScroll.setHasMessages(has), { immediate: true });
|
|
86
|
+
useAuiEvent("thread.runStart", () => autoScroll.runStarted());
|
|
87
|
+
useAuiEvent("threads.selectionChanged", () => autoScroll.threadSwitched());
|
|
159
88
|
provide(viewportInjectionKey, {
|
|
160
89
|
isAtBottom,
|
|
161
|
-
scrollToBottom: (behavior = "auto") => scrollToBottom(behavior),
|
|
90
|
+
scrollToBottom: (behavior = "auto") => autoScroll.scrollToBottom(behavior),
|
|
162
91
|
registerContentInset
|
|
163
92
|
});
|
|
164
|
-
return () => h("div", {
|
|
165
|
-
ref: divRef,
|
|
166
|
-
onScroll: handleScroll,
|
|
167
|
-
onPointerdown
|
|
168
|
-
}, slots.default?.());
|
|
93
|
+
return () => h("div", { ref: divRef }, slots.default?.());
|
|
169
94
|
}
|
|
170
95
|
});
|
|
171
96
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreadPrimitiveViewport.js","names":[],"sources":["../../src/primitives/ThreadPrimitiveViewport.ts"],"sourcesContent":["import {\n defineComponent,\n h,\n onMounted,\n onScopeDispose,\n provide,\n shallowRef,\n watch,\n type SlotsType,\n type VNodeChild,\n} from \"vue\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { useAuiEvent } from \"../useAuiEvent\";\nimport { useAuiState } from \"../useAuiState\";\nimport {\n isUserScrollUp,\n isViewportAtBottom,\n observeContentResize,\n viewportOverflows,\n} from \"@assistant-ui/store/client\";\nimport { viewportInjectionKey } from \"./viewportContext\";\n\n/**\n * A scrollable container that keeps the thread pinned to the bottom: content\n * growth scrolls back down while the user sits at the bottom, a run start\n * scrolls down, and scrolling up unpins until the user returns to the bottom.\n * The four options mirror the React hook and are independent: `autoScroll`\n * covers follow-on-content-growth, and the other three gate the\n * first-messages, run-start, and thread-switch scrolls. Provides the\n * scroll-to-bottom channel that {@link ThreadPrimitiveScrollToBottom} drives;\n * the React viewport's top-anchor system stays in the React viewport store\n * and is not ported.\n */\nexport const ThreadPrimitiveViewport = defineComponent({\n name: \"ThreadPrimitiveViewport\",\n props: {\n autoScroll: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnInitialize: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnRunStart: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnThreadSwitch: {\n type: Boolean,\n default: true,\n },\n },\n slots: Object as SlotsType<{ default?: () => VNodeChild[] }>,\n setup(props, { slots }) {\n const divRef = shallowRef<HTMLElement | null>(null);\n const contentInset = shallowRef(0);\n const contentInsetEntries = new Map<symbol, number>();\n let intent: ScrollBehavior | null = null;\n const isAtBottom = shallowRef(true);\n let lastScrollTop = 0;\n let lastScrollHeight = 0;\n let lastObservedScrollHeight = 0;\n let lastObservedClientHeight = 0;\n let frame: number | null = null;\n\n const scrollToBottom = (behavior: ScrollBehavior) => {\n const div = divRef.value;\n if (!div) return;\n intent = behavior;\n div.scrollTo?.({ top: div.scrollHeight, behavior });\n };\n\n const scheduleScrollToBottom = (behavior: ScrollBehavior) => {\n intent = behavior;\n // The immediate watch below runs synchronously during SSR, where no\n // frame scheduler exists.\n if (typeof requestAnimationFrame === \"undefined\") return;\n if (frame !== null) cancelAnimationFrame(frame);\n frame = requestAnimationFrame(() => {\n frame = null;\n scrollToBottom(behavior);\n });\n };\n\n const handleScroll = () => {\n const div = divRef.value;\n if (!div) return;\n\n const newIsAtBottom = isViewportAtBottom(div, contentInset.value);\n const inFlightDownward = !newIsAtBottom && lastScrollTop < div.scrollTop;\n if (!inFlightDownward) {\n if (newIsAtBottom) {\n // At-bottom is ambiguous while the viewport does not overflow; keep\n // the intent alive until content can actually scroll.\n if (viewportOverflows(div, contentInset.value)) intent = null;\n } else if (\n isUserScrollUp(\n { scrollTop: lastScrollTop, scrollHeight: lastScrollHeight },\n div,\n )\n ) {\n intent = null;\n }\n if (newIsAtBottom || intent === null) isAtBottom.value = newIsAtBottom;\n }\n\n lastScrollTop = div.scrollTop;\n lastScrollHeight = div.scrollHeight;\n };\n\n const updateContentInset = () => {\n let total = 0;\n for (const height of contentInsetEntries.values()) total += height;\n if (contentInset.value === total) return;\n const grew = total > contentInset.value;\n contentInset.value = total;\n // A growing inset obscures content a pinned viewport was showing, so it\n // follows like a content resize; a shrinking inset reveals content and\n // must not move the viewport.\n if (grew) {\n if (intent) {\n scrollToBottom(intent);\n } else if (props.autoScroll && isAtBottom.value) {\n scrollToBottom(\"instant\");\n }\n }\n handleScroll();\n };\n\n const registerContentInset = () => {\n const id = Symbol();\n contentInsetEntries.set(id, 0);\n\n return {\n setHeight: (height: number) => {\n if (contentInsetEntries.get(id) === height) return;\n contentInsetEntries.set(id, height);\n updateContentInset();\n },\n unregister: () => {\n if (!contentInsetEntries.delete(id)) return;\n updateContentInset();\n },\n };\n };\n\n const onContentResize = () => {\n const div = divRef.value;\n if (!div) return;\n const { scrollHeight, clientHeight } = div;\n if (\n scrollHeight === lastObservedScrollHeight &&\n clientHeight === lastObservedClientHeight\n ) {\n return;\n }\n lastObservedScrollHeight = scrollHeight;\n lastObservedClientHeight = clientHeight;\n\n if (intent) {\n scrollToBottom(intent);\n } else if (props.autoScroll && isAtBottom.value) {\n scrollToBottom(\"instant\");\n }\n handleScroll();\n };\n\n // A pointer gesture invalidates pending bottom-scroll intent; otherwise an\n // intent kept alive by a non-overflowing thread hijacks the next content\n // growth. Unlike the React hook, an already scheduled frame is cancelled\n // too, so the gesture also wins the race against a just-planted intent.\n const onPointerdown = () => {\n intent = null;\n if (frame !== null) {\n cancelAnimationFrame(frame);\n frame = null;\n }\n };\n\n let disconnect: (() => void) | undefined;\n onMounted(() => {\n const div = divRef.value;\n if (!div) return;\n disconnect = observeContentResize(div, onContentResize);\n });\n onScopeDispose(() => {\n disconnect?.();\n if (frame !== null) cancelAnimationFrame(frame);\n });\n\n const hasMessages = useAuiState((s) => s.thread.messages.length > 0);\n let initialized = false;\n watch(\n [hasMessages, () => props.scrollToBottomOnInitialize],\n ([has, enabled]) => {\n if (!has) {\n initialized = false;\n return;\n }\n if (!enabled || initialized) return;\n initialized = true;\n if (intent !== null) return;\n scheduleScrollToBottom(\"instant\");\n },\n { immediate: true },\n );\n\n useAuiEvent(\"thread.runStart\", () => {\n if (!props.scrollToBottomOnRunStart) return;\n scheduleScrollToBottom(\"auto\");\n });\n\n useAuiEvent(\"threads.selectionChanged\", () => {\n if (!props.scrollToBottomOnThreadSwitch) return;\n scheduleScrollToBottom(\"instant\");\n });\n\n provide(viewportInjectionKey, {\n isAtBottom,\n scrollToBottom: (behavior: ScrollBehavior = \"auto\") =>\n scrollToBottom(behavior),\n registerContentInset,\n });\n\n return () =>\n h(\n \"div\",\n { ref: divRef, onScroll: handleScroll, onPointerdown },\n slots.default?.(),\n );\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;AAiCA,MAAa,0BAA0B,gBAAgB;CACrD,MAAM;CACN,OAAO;EACL,YAAY;GACV,MAAM;GACN,SAAS;EACX;EACA,4BAA4B;GAC1B,MAAM;GACN,SAAS;EACX;EACA,0BAA0B;GACxB,MAAM;GACN,SAAS;EACX;EACA,8BAA8B;GAC5B,MAAM;GACN,SAAS;EACX;CACF;CACA,OAAO;CACP,MAAM,OAAO,EAAE,SAAS;EACtB,MAAM,SAAS,WAA+B,IAAI;EAClD,MAAM,eAAe,WAAW,CAAC;EACjC,MAAM,sCAAsB,IAAI,IAAoB;EACpD,IAAI,SAAgC;EACpC,MAAM,aAAa,WAAW,IAAI;EAClC,IAAI,gBAAgB;EACpB,IAAI,mBAAmB;EACvB,IAAI,2BAA2B;EAC/B,IAAI,2BAA2B;EAC/B,IAAI,QAAuB;EAE3B,MAAM,kBAAkB,aAA6B;GACnD,MAAM,MAAM,OAAO;GACnB,IAAI,CAAC,KAAK;GACV,SAAS;GACT,IAAI,WAAW;IAAE,KAAK,IAAI;IAAc;GAAS,CAAC;EACpD;EAEA,MAAM,0BAA0B,aAA6B;GAC3D,SAAS;GAGT,IAAI,OAAO,0BAA0B,aAAa;GAClD,IAAI,UAAU,MAAM,qBAAqB,KAAK;GAC9C,QAAQ,4BAA4B;IAClC,QAAQ;IACR,eAAe,QAAQ;GACzB,CAAC;EACH;EAEA,MAAM,qBAAqB;GACzB,MAAM,MAAM,OAAO;GACnB,IAAI,CAAC,KAAK;GAEV,MAAM,gBAAgB,mBAAmB,KAAK,aAAa,KAAK;GAEhE,IAAI,EADqB,CAAC,iBAAiB,gBAAgB,IAAI,YACxC;IACrB,IAAI,eAGE;SAAA,kBAAkB,KAAK,aAAa,KAAK,GAAG,SAAS;IAAA,OACpD,IACL,eACE;KAAE,WAAW;KAAe,cAAc;IAAiB,GAC3D,GACF,GAEA,SAAS;IAEX,IAAI,iBAAiB,WAAW,MAAM,WAAW,QAAQ;GAC3D;GAEA,gBAAgB,IAAI;GACpB,mBAAmB,IAAI;EACzB;EAEA,MAAM,2BAA2B;GAC/B,IAAI,QAAQ;GACZ,KAAK,MAAM,UAAU,oBAAoB,OAAO,GAAG,SAAS;GAC5D,IAAI,aAAa,UAAU,OAAO;GAClC,MAAM,OAAO,QAAQ,aAAa;GAClC,aAAa,QAAQ;GAIrB,IAAI,MAAM;IACR,IAAI,QACF,eAAe,MAAM;SAChB,IAAI,MAAM,cAAc,WAAW,OACxC,eAAe,SAAS;GAE5B;GACA,aAAa;EACf;EAEA,MAAM,6BAA6B;GACjC,MAAM,KAAK,OAAO;GAClB,oBAAoB,IAAI,IAAI,CAAC;GAE7B,OAAO;IACL,YAAY,WAAmB;KAC7B,IAAI,oBAAoB,IAAI,EAAE,MAAM,QAAQ;KAC5C,oBAAoB,IAAI,IAAI,MAAM;KAClC,mBAAmB;IACrB;IACA,kBAAkB;KAChB,IAAI,CAAC,oBAAoB,OAAO,EAAE,GAAG;KACrC,mBAAmB;IACrB;GACF;EACF;EAEA,MAAM,wBAAwB;GAC5B,MAAM,MAAM,OAAO;GACnB,IAAI,CAAC,KAAK;GACV,MAAM,EAAE,cAAc,iBAAiB;GACvC,IACE,iBAAiB,4BACjB,iBAAiB,0BAEjB;GAEF,2BAA2B;GAC3B,2BAA2B;GAE3B,IAAI,QACF,eAAe,MAAM;QAChB,IAAI,MAAM,cAAc,WAAW,OACxC,eAAe,SAAS;GAE1B,aAAa;EACf;EAMA,MAAM,sBAAsB;GAC1B,SAAS;GACT,IAAI,UAAU,MAAM;IAClB,qBAAqB,KAAK;IAC1B,QAAQ;GACV;EACF;EAEA,IAAI;EACJ,gBAAgB;GACd,MAAM,MAAM,OAAO;GACnB,IAAI,CAAC,KAAK;GACV,aAAa,qBAAqB,KAAK,eAAe;EACxD,CAAC;EACD,qBAAqB;GACnB,aAAa;GACb,IAAI,UAAU,MAAM,qBAAqB,KAAK;EAChD,CAAC;EAED,MAAM,cAAc,aAAa,MAAM,EAAE,OAAO,SAAS,SAAS,CAAC;EACnE,IAAI,cAAc;EAClB,MACE,CAAC,mBAAmB,MAAM,0BAA0B,IACnD,CAAC,KAAK,aAAa;GAClB,IAAI,CAAC,KAAK;IACR,cAAc;IACd;GACF;GACA,IAAI,CAAC,WAAW,aAAa;GAC7B,cAAc;GACd,IAAI,WAAW,MAAM;GACrB,uBAAuB,SAAS;EAClC,GACA,EAAE,WAAW,KAAK,CACpB;EAEA,YAAY,yBAAyB;GACnC,IAAI,CAAC,MAAM,0BAA0B;GACrC,uBAAuB,MAAM;EAC/B,CAAC;EAED,YAAY,kCAAkC;GAC5C,IAAI,CAAC,MAAM,8BAA8B;GACzC,uBAAuB,SAAS;EAClC,CAAC;EAED,QAAQ,sBAAsB;GAC5B;GACA,iBAAiB,WAA2B,WAC1C,eAAe,QAAQ;GACzB;EACF,CAAC;EAED,aACE,EACE,OACA;GAAE,KAAK;GAAQ,UAAU;GAAc;EAAc,GACrD,MAAM,UAAU,CAClB;CACJ;AACF,CAAC"}
|
|
1
|
+
{"version":3,"file":"ThreadPrimitiveViewport.js","names":[],"sources":["../../src/primitives/ThreadPrimitiveViewport.ts"],"sourcesContent":["import {\n defineComponent,\n h,\n onMounted,\n onScopeDispose,\n provide,\n shallowRef,\n watch,\n type SlotsType,\n type VNodeChild,\n} from \"vue\";\nimport type {} from \"@assistant-ui/core/store\";\nimport { useAuiEvent } from \"../useAuiEvent\";\nimport { useAuiState } from \"../useAuiState\";\nimport { createThreadViewportAutoScroll } from \"@assistant-ui/store/client\";\nimport { viewportInjectionKey } from \"./viewportContext\";\n\n/**\n * A scrollable container that keeps the thread pinned to the bottom: content\n * growth scrolls back down while the user sits at the bottom, a run start\n * scrolls down, and scrolling up unpins until the user returns to the bottom.\n * The four options mirror the React hook and are independent: `autoScroll`\n * covers follow-on-content-growth, and the other three gate the\n * first-messages, run-start, and thread-switch scrolls. Provides the\n * scroll-to-bottom channel that {@link ThreadPrimitiveScrollToBottom} drives;\n * the React viewport's top-anchor system stays in the React viewport store\n * and is not ported.\n */\nexport const ThreadPrimitiveViewport = defineComponent({\n name: \"ThreadPrimitiveViewport\",\n props: {\n autoScroll: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnInitialize: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnRunStart: {\n type: Boolean,\n default: true,\n },\n scrollToBottomOnThreadSwitch: {\n type: Boolean,\n default: true,\n },\n },\n slots: Object as SlotsType<{ default?: () => VNodeChild[] }>,\n setup(props, { slots }) {\n const divRef = shallowRef<HTMLElement | null>(null);\n const contentInsetEntries = new Map<symbol, number>();\n const isAtBottom = shallowRef(true);\n const autoScroll = createThreadViewportAutoScroll({\n getOptions: () => ({\n autoScroll: props.autoScroll,\n scrollToBottomOnInitialize: props.scrollToBottomOnInitialize,\n scrollToBottomOnRunStart: props.scrollToBottomOnRunStart,\n scrollToBottomOnThreadSwitch: props.scrollToBottomOnThreadSwitch,\n }),\n onAtBottomChange: (value) => {\n isAtBottom.value = value;\n },\n });\n\n const updateContentInset = () => {\n let total = 0;\n for (const height of contentInsetEntries.values()) total += height;\n autoScroll.setContentInset(total);\n };\n\n const registerContentInset = () => {\n const id = Symbol();\n contentInsetEntries.set(id, 0);\n\n return {\n setHeight: (height: number) => {\n if (contentInsetEntries.get(id) === height) return;\n contentInsetEntries.set(id, height);\n updateContentInset();\n },\n unregister: () => {\n if (!contentInsetEntries.delete(id)) return;\n updateContentInset();\n },\n };\n };\n\n let detach: (() => void) | undefined;\n onMounted(() => {\n const div = divRef.value;\n if (!div) return;\n detach = autoScroll.attach(div);\n });\n onScopeDispose(() => {\n detach?.();\n autoScroll.dispose();\n });\n\n const hasMessages = useAuiState((s) => s.thread.messages.length > 0);\n watch(\n [hasMessages, () => props.scrollToBottomOnInitialize],\n ([has]) => autoScroll.setHasMessages(has),\n { immediate: true },\n );\n\n useAuiEvent(\"thread.runStart\", () => autoScroll.runStarted());\n\n useAuiEvent(\"threads.selectionChanged\", () => autoScroll.threadSwitched());\n\n provide(viewportInjectionKey, {\n isAtBottom,\n scrollToBottom: (behavior: ScrollBehavior = \"auto\") =>\n autoScroll.scrollToBottom(behavior),\n registerContentInset,\n });\n\n return () => h(\"div\", { ref: divRef }, slots.default?.());\n },\n});\n"],"mappings":";;;;;;;;;;;;;;;;;AA4BA,MAAa,0BAA0B,gBAAgB;CACrD,MAAM;CACN,OAAO;EACL,YAAY;GACV,MAAM;GACN,SAAS;EACX;EACA,4BAA4B;GAC1B,MAAM;GACN,SAAS;EACX;EACA,0BAA0B;GACxB,MAAM;GACN,SAAS;EACX;EACA,8BAA8B;GAC5B,MAAM;GACN,SAAS;EACX;CACF;CACA,OAAO;CACP,MAAM,OAAO,EAAE,SAAS;EACtB,MAAM,SAAS,WAA+B,IAAI;EAClD,MAAM,sCAAsB,IAAI,IAAoB;EACpD,MAAM,aAAa,WAAW,IAAI;EAClC,MAAM,aAAa,+BAA+B;GAChD,mBAAmB;IACjB,YAAY,MAAM;IAClB,4BAA4B,MAAM;IAClC,0BAA0B,MAAM;IAChC,8BAA8B,MAAM;GACtC;GACA,mBAAmB,UAAU;IAC3B,WAAW,QAAQ;GACrB;EACF,CAAC;EAED,MAAM,2BAA2B;GAC/B,IAAI,QAAQ;GACZ,KAAK,MAAM,UAAU,oBAAoB,OAAO,GAAG,SAAS;GAC5D,WAAW,gBAAgB,KAAK;EAClC;EAEA,MAAM,6BAA6B;GACjC,MAAM,KAAK,OAAO;GAClB,oBAAoB,IAAI,IAAI,CAAC;GAE7B,OAAO;IACL,YAAY,WAAmB;KAC7B,IAAI,oBAAoB,IAAI,EAAE,MAAM,QAAQ;KAC5C,oBAAoB,IAAI,IAAI,MAAM;KAClC,mBAAmB;IACrB;IACA,kBAAkB;KAChB,IAAI,CAAC,oBAAoB,OAAO,EAAE,GAAG;KACrC,mBAAmB;IACrB;GACF;EACF;EAEA,IAAI;EACJ,gBAAgB;GACd,MAAM,MAAM,OAAO;GACnB,IAAI,CAAC,KAAK;GACV,SAAS,WAAW,OAAO,GAAG;EAChC,CAAC;EACD,qBAAqB;GACnB,SAAS;GACT,WAAW,QAAQ;EACrB,CAAC;EAED,MAAM,cAAc,aAAa,MAAM,EAAE,OAAO,SAAS,SAAS,CAAC;EACnE,MACE,CAAC,mBAAmB,MAAM,0BAA0B,IACnD,CAAC,SAAS,WAAW,eAAe,GAAG,GACxC,EAAE,WAAW,KAAK,CACpB;EAEA,YAAY,yBAAyB,WAAW,WAAW,CAAC;EAE5D,YAAY,kCAAkC,WAAW,eAAe,CAAC;EAEzE,QAAQ,sBAAsB;GAC5B;GACA,iBAAiB,WAA2B,WAC1C,WAAW,eAAe,QAAQ;GACpC;EACF,CAAC;EAED,aAAa,EAAE,OAAO,EAAE,KAAK,OAAO,GAAG,MAAM,UAAU,CAAC;CAC1D;AACF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/vue",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Vue bindings for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assistant",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@assistant-ui/core": "^0.3.
|
|
31
|
-
"@assistant-ui/store": "^0.3.
|
|
32
|
-
"@assistant-ui/tap": "^0.9.
|
|
30
|
+
"@assistant-ui/core": "^0.3.25",
|
|
31
|
+
"@assistant-ui/store": "^0.3.18",
|
|
32
|
+
"@assistant-ui/tap": "^0.9.22"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@types/react": "*",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@assistant-ui/x-buildutils": "0.0.30",
|
|
40
|
-
"@types/node": "^26.6.
|
|
40
|
+
"@types/node": "^26.6.4",
|
|
41
41
|
"@types/react": "^19.3.0",
|
|
42
|
-
"assistant-cloud": "0.2.
|
|
43
|
-
"jsdom": "^30.
|
|
42
|
+
"assistant-cloud": "0.2.6",
|
|
43
|
+
"jsdom": "^30.1.2",
|
|
44
44
|
"vitest": "^5.0.3",
|
|
45
45
|
"vue": "^3.5.43"
|
|
46
46
|
},
|
|
@@ -22,6 +22,7 @@ import { useAuiState } from "../useAuiState";
|
|
|
22
22
|
import { ThreadPrimitiveMessages } from "../primitives/ThreadPrimitiveMessages";
|
|
23
23
|
import { ThreadPrimitiveViewport } from "../primitives/ThreadPrimitiveViewport";
|
|
24
24
|
import { ThreadPrimitiveViewportFooter } from "../primitives/ThreadPrimitiveViewportFooter";
|
|
25
|
+
import { MessagePrimitiveRoot } from "../primitives/message";
|
|
25
26
|
import {
|
|
26
27
|
ThreadPrimitiveScrollToBottom,
|
|
27
28
|
clearScrollToBottomWarningForTesting,
|
|
@@ -377,6 +378,85 @@ describe("ThreadPrimitiveViewport", () => {
|
|
|
377
378
|
unmount();
|
|
378
379
|
});
|
|
379
380
|
|
|
381
|
+
it("pauses following when a message disclosure expands", async () => {
|
|
382
|
+
const { runtime, append } = createTestRuntime();
|
|
383
|
+
const View = defineComponent({
|
|
384
|
+
setup: () => () =>
|
|
385
|
+
h(
|
|
386
|
+
ThreadPrimitiveViewport,
|
|
387
|
+
{
|
|
388
|
+
class: "viewport",
|
|
389
|
+
scrollToBottomOnInitialize: false,
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
default: () =>
|
|
393
|
+
h(ThreadPrimitiveMessages, null, {
|
|
394
|
+
default: () =>
|
|
395
|
+
h(MessagePrimitiveRoot, null, {
|
|
396
|
+
default: () =>
|
|
397
|
+
h(
|
|
398
|
+
"button",
|
|
399
|
+
{ class: "disclosure", "aria-expanded": "false" },
|
|
400
|
+
"Expand",
|
|
401
|
+
),
|
|
402
|
+
}),
|
|
403
|
+
}),
|
|
404
|
+
},
|
|
405
|
+
),
|
|
406
|
+
});
|
|
407
|
+
const { el, unmount } = mountChat(runtime, View);
|
|
408
|
+
const div = el.querySelector<HTMLElement>("div.viewport")!;
|
|
409
|
+
let scrollHeight = 500;
|
|
410
|
+
let scrollTop = 400;
|
|
411
|
+
Object.defineProperties(div, {
|
|
412
|
+
scrollHeight: {
|
|
413
|
+
get: () => scrollHeight,
|
|
414
|
+
configurable: true,
|
|
415
|
+
},
|
|
416
|
+
clientHeight: { value: 100, configurable: true },
|
|
417
|
+
scrollTop: {
|
|
418
|
+
get: () => scrollTop,
|
|
419
|
+
set: (value: number) => {
|
|
420
|
+
scrollTop = value;
|
|
421
|
+
},
|
|
422
|
+
configurable: true,
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
const scrollTo = vi.fn(({ top }: { top: number }) => {
|
|
426
|
+
scrollTop = Math.max(0, Math.min(top, scrollHeight - 100));
|
|
427
|
+
div.dispatchEvent(new Event("scroll"));
|
|
428
|
+
});
|
|
429
|
+
Object.defineProperty(div, "scrollTo", {
|
|
430
|
+
value: scrollTo,
|
|
431
|
+
configurable: true,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
flushTapSync(() =>
|
|
435
|
+
append({ role: "assistant", content: [{ type: "text", text: "one" }] }),
|
|
436
|
+
);
|
|
437
|
+
await vi.waitFor(async () => {
|
|
438
|
+
await nextTick();
|
|
439
|
+
expect(el.querySelector("[data-message-id]")).not.toBeNull();
|
|
440
|
+
expect(scrollTo).toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
scrollTo.mockClear();
|
|
443
|
+
|
|
444
|
+
const disclosure =
|
|
445
|
+
el.querySelector<HTMLButtonElement>("button.disclosure")!;
|
|
446
|
+
disclosure.addEventListener("click", (event) => {
|
|
447
|
+
disclosure.setAttribute("aria-expanded", "true");
|
|
448
|
+
event.stopPropagation();
|
|
449
|
+
});
|
|
450
|
+
disclosure.click();
|
|
451
|
+
scrollHeight = 600;
|
|
452
|
+
el.querySelector("[data-message-id]")!.append(document.createElement("p"));
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
454
|
+
|
|
455
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
456
|
+
expect(scrollTop).toBe(400);
|
|
457
|
+
unmount();
|
|
458
|
+
});
|
|
459
|
+
|
|
380
460
|
it("stays put when every scroll option is disabled", async () => {
|
|
381
461
|
const { runtime, append } = createTestRuntime();
|
|
382
462
|
const View = defineComponent({
|
|
@@ -166,6 +166,53 @@ describe("vue primitives", () => {
|
|
|
166
166
|
unmount();
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
it("does not submit on Safari's post-compositionend Enter", async () => {
|
|
170
|
+
const { runtime, onNew } = createTestRuntime();
|
|
171
|
+
const View = defineComponent({
|
|
172
|
+
setup: () => () => h(ComposerPrimitiveInput),
|
|
173
|
+
});
|
|
174
|
+
const { el, unmount } = mountChat(runtime, View);
|
|
175
|
+
|
|
176
|
+
const textarea = el.querySelector("textarea")!;
|
|
177
|
+
textarea.dispatchEvent(
|
|
178
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
179
|
+
);
|
|
180
|
+
textarea.value = "hello";
|
|
181
|
+
textarea.dispatchEvent(
|
|
182
|
+
new InputEvent("input", { bubbles: true, isComposing: true }),
|
|
183
|
+
);
|
|
184
|
+
textarea.dispatchEvent(
|
|
185
|
+
new CompositionEvent("compositionend", {
|
|
186
|
+
data: "hello",
|
|
187
|
+
bubbles: true,
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const composingEnter = new KeyboardEvent("keydown", {
|
|
192
|
+
key: "Enter",
|
|
193
|
+
keyCode: 229,
|
|
194
|
+
isComposing: false,
|
|
195
|
+
cancelable: true,
|
|
196
|
+
});
|
|
197
|
+
expect(textarea.dispatchEvent(composingEnter)).toBe(true);
|
|
198
|
+
await vi.waitFor(() => {
|
|
199
|
+
expect(runtime.thread.composer.getState().text).toBe("hello");
|
|
200
|
+
});
|
|
201
|
+
expect(onNew).not.toHaveBeenCalled();
|
|
202
|
+
|
|
203
|
+
const plainEnter = new KeyboardEvent("keydown", {
|
|
204
|
+
key: "Enter",
|
|
205
|
+
keyCode: 13,
|
|
206
|
+
cancelable: true,
|
|
207
|
+
});
|
|
208
|
+
expect(textarea.dispatchEvent(plainEnter)).toBe(false);
|
|
209
|
+
await vi.waitFor(() => {
|
|
210
|
+
expect(onNew).toHaveBeenCalledTimes(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
unmount();
|
|
214
|
+
});
|
|
215
|
+
|
|
169
216
|
it("does not submit on Enter when submitOnEnter is false", async () => {
|
|
170
217
|
const { runtime, onNew } = createTestRuntime();
|
|
171
218
|
const View = defineComponent({
|
|
@@ -43,7 +43,13 @@ export const ComposerPrimitiveInput = defineComponent({
|
|
|
43
43
|
};
|
|
44
44
|
const onKeydown = (event: KeyboardEvent) => {
|
|
45
45
|
if (event.defaultPrevented || !props.submitOnEnter) return;
|
|
46
|
-
if (
|
|
46
|
+
if (
|
|
47
|
+
event.key !== "Enter" ||
|
|
48
|
+
event.shiftKey ||
|
|
49
|
+
event.isComposing ||
|
|
50
|
+
event.keyCode === 229
|
|
51
|
+
)
|
|
52
|
+
return;
|
|
47
53
|
if (disabled.value || threadDisabled.value || isAttrDisabled(attrs))
|
|
48
54
|
return;
|
|
49
55
|
event.preventDefault();
|
|
@@ -41,6 +41,7 @@ export type ToolUIProps = {
|
|
|
41
41
|
addResult: PartMethods["addToolResult"];
|
|
42
42
|
resume: PartMethods["resumeToolCall"];
|
|
43
43
|
respondToApproval: PartMethods["respondToToolApproval"];
|
|
44
|
+
/** @deprecated Experimental since 2026-09-23. Not scheduled for removal; the API may change in any release. */
|
|
44
45
|
unstable_recordInteraction?: PartMethods["unstable_recordInteraction"];
|
|
45
46
|
};
|
|
46
47
|
|
|
@@ -7,11 +7,9 @@ import { useStableKeys } from "./stableKeys";
|
|
|
7
7
|
/**
|
|
8
8
|
* Renders the default slot once per message in the current thread, each
|
|
9
9
|
* instance scoped to its message through {@link MessageByIdProvider} and
|
|
10
|
-
* keyed by the message id
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* response is its own identity, so the arrival of the real assistant message
|
|
14
|
-
* remounts that one row (a leave/enter pair under `<TransitionGroup>`).
|
|
10
|
+
* keyed by the message id. A stable id keeps its row component instance across
|
|
11
|
+
* updates; a new id remounts the row. Because rows render inside this
|
|
12
|
+
* component, an outer `<TransitionGroup>` cannot animate individual messages.
|
|
15
13
|
*
|
|
16
14
|
* @example
|
|
17
15
|
* ```html
|
|
@@ -12,12 +12,7 @@ import {
|
|
|
12
12
|
import type {} from "@assistant-ui/core/store";
|
|
13
13
|
import { useAuiEvent } from "../useAuiEvent";
|
|
14
14
|
import { useAuiState } from "../useAuiState";
|
|
15
|
-
import {
|
|
16
|
-
isUserScrollUp,
|
|
17
|
-
isViewportAtBottom,
|
|
18
|
-
observeContentResize,
|
|
19
|
-
viewportOverflows,
|
|
20
|
-
} from "@assistant-ui/store/client";
|
|
15
|
+
import { createThreadViewportAutoScroll } from "@assistant-ui/store/client";
|
|
21
16
|
import { viewportInjectionKey } from "./viewportContext";
|
|
22
17
|
|
|
23
18
|
/**
|
|
@@ -54,78 +49,24 @@ export const ThreadPrimitiveViewport = defineComponent({
|
|
|
54
49
|
slots: Object as SlotsType<{ default?: () => VNodeChild[] }>,
|
|
55
50
|
setup(props, { slots }) {
|
|
56
51
|
const divRef = shallowRef<HTMLElement | null>(null);
|
|
57
|
-
const contentInset = shallowRef(0);
|
|
58
52
|
const contentInsetEntries = new Map<symbol, number>();
|
|
59
|
-
let intent: ScrollBehavior | null = null;
|
|
60
53
|
const isAtBottom = shallowRef(true);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const scheduleScrollToBottom = (behavior: ScrollBehavior) => {
|
|
75
|
-
intent = behavior;
|
|
76
|
-
// The immediate watch below runs synchronously during SSR, where no
|
|
77
|
-
// frame scheduler exists.
|
|
78
|
-
if (typeof requestAnimationFrame === "undefined") return;
|
|
79
|
-
if (frame !== null) cancelAnimationFrame(frame);
|
|
80
|
-
frame = requestAnimationFrame(() => {
|
|
81
|
-
frame = null;
|
|
82
|
-
scrollToBottom(behavior);
|
|
83
|
-
});
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const handleScroll = () => {
|
|
87
|
-
const div = divRef.value;
|
|
88
|
-
if (!div) return;
|
|
89
|
-
|
|
90
|
-
const newIsAtBottom = isViewportAtBottom(div, contentInset.value);
|
|
91
|
-
const inFlightDownward = !newIsAtBottom && lastScrollTop < div.scrollTop;
|
|
92
|
-
if (!inFlightDownward) {
|
|
93
|
-
if (newIsAtBottom) {
|
|
94
|
-
// At-bottom is ambiguous while the viewport does not overflow; keep
|
|
95
|
-
// the intent alive until content can actually scroll.
|
|
96
|
-
if (viewportOverflows(div, contentInset.value)) intent = null;
|
|
97
|
-
} else if (
|
|
98
|
-
isUserScrollUp(
|
|
99
|
-
{ scrollTop: lastScrollTop, scrollHeight: lastScrollHeight },
|
|
100
|
-
div,
|
|
101
|
-
)
|
|
102
|
-
) {
|
|
103
|
-
intent = null;
|
|
104
|
-
}
|
|
105
|
-
if (newIsAtBottom || intent === null) isAtBottom.value = newIsAtBottom;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
lastScrollTop = div.scrollTop;
|
|
109
|
-
lastScrollHeight = div.scrollHeight;
|
|
110
|
-
};
|
|
54
|
+
const autoScroll = createThreadViewportAutoScroll({
|
|
55
|
+
getOptions: () => ({
|
|
56
|
+
autoScroll: props.autoScroll,
|
|
57
|
+
scrollToBottomOnInitialize: props.scrollToBottomOnInitialize,
|
|
58
|
+
scrollToBottomOnRunStart: props.scrollToBottomOnRunStart,
|
|
59
|
+
scrollToBottomOnThreadSwitch: props.scrollToBottomOnThreadSwitch,
|
|
60
|
+
}),
|
|
61
|
+
onAtBottomChange: (value) => {
|
|
62
|
+
isAtBottom.value = value;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
111
65
|
|
|
112
66
|
const updateContentInset = () => {
|
|
113
67
|
let total = 0;
|
|
114
68
|
for (const height of contentInsetEntries.values()) total += height;
|
|
115
|
-
|
|
116
|
-
const grew = total > contentInset.value;
|
|
117
|
-
contentInset.value = total;
|
|
118
|
-
// A growing inset obscures content a pinned viewport was showing, so it
|
|
119
|
-
// follows like a content resize; a shrinking inset reveals content and
|
|
120
|
-
// must not move the viewport.
|
|
121
|
-
if (grew) {
|
|
122
|
-
if (intent) {
|
|
123
|
-
scrollToBottom(intent);
|
|
124
|
-
} else if (props.autoScroll && isAtBottom.value) {
|
|
125
|
-
scrollToBottom("instant");
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
handleScroll();
|
|
69
|
+
autoScroll.setContentInset(total);
|
|
129
70
|
};
|
|
130
71
|
|
|
131
72
|
const registerContentInset = () => {
|
|
@@ -145,89 +86,35 @@ export const ThreadPrimitiveViewport = defineComponent({
|
|
|
145
86
|
};
|
|
146
87
|
};
|
|
147
88
|
|
|
148
|
-
|
|
149
|
-
const div = divRef.value;
|
|
150
|
-
if (!div) return;
|
|
151
|
-
const { scrollHeight, clientHeight } = div;
|
|
152
|
-
if (
|
|
153
|
-
scrollHeight === lastObservedScrollHeight &&
|
|
154
|
-
clientHeight === lastObservedClientHeight
|
|
155
|
-
) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
lastObservedScrollHeight = scrollHeight;
|
|
159
|
-
lastObservedClientHeight = clientHeight;
|
|
160
|
-
|
|
161
|
-
if (intent) {
|
|
162
|
-
scrollToBottom(intent);
|
|
163
|
-
} else if (props.autoScroll && isAtBottom.value) {
|
|
164
|
-
scrollToBottom("instant");
|
|
165
|
-
}
|
|
166
|
-
handleScroll();
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// A pointer gesture invalidates pending bottom-scroll intent; otherwise an
|
|
170
|
-
// intent kept alive by a non-overflowing thread hijacks the next content
|
|
171
|
-
// growth. Unlike the React hook, an already scheduled frame is cancelled
|
|
172
|
-
// too, so the gesture also wins the race against a just-planted intent.
|
|
173
|
-
const onPointerdown = () => {
|
|
174
|
-
intent = null;
|
|
175
|
-
if (frame !== null) {
|
|
176
|
-
cancelAnimationFrame(frame);
|
|
177
|
-
frame = null;
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
let disconnect: (() => void) | undefined;
|
|
89
|
+
let detach: (() => void) | undefined;
|
|
182
90
|
onMounted(() => {
|
|
183
91
|
const div = divRef.value;
|
|
184
92
|
if (!div) return;
|
|
185
|
-
|
|
93
|
+
detach = autoScroll.attach(div);
|
|
186
94
|
});
|
|
187
95
|
onScopeDispose(() => {
|
|
188
|
-
|
|
189
|
-
|
|
96
|
+
detach?.();
|
|
97
|
+
autoScroll.dispose();
|
|
190
98
|
});
|
|
191
99
|
|
|
192
100
|
const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
|
|
193
|
-
let initialized = false;
|
|
194
101
|
watch(
|
|
195
102
|
[hasMessages, () => props.scrollToBottomOnInitialize],
|
|
196
|
-
([has
|
|
197
|
-
if (!has) {
|
|
198
|
-
initialized = false;
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (!enabled || initialized) return;
|
|
202
|
-
initialized = true;
|
|
203
|
-
if (intent !== null) return;
|
|
204
|
-
scheduleScrollToBottom("instant");
|
|
205
|
-
},
|
|
103
|
+
([has]) => autoScroll.setHasMessages(has),
|
|
206
104
|
{ immediate: true },
|
|
207
105
|
);
|
|
208
106
|
|
|
209
|
-
useAuiEvent("thread.runStart", () =>
|
|
210
|
-
if (!props.scrollToBottomOnRunStart) return;
|
|
211
|
-
scheduleScrollToBottom("auto");
|
|
212
|
-
});
|
|
107
|
+
useAuiEvent("thread.runStart", () => autoScroll.runStarted());
|
|
213
108
|
|
|
214
|
-
useAuiEvent("threads.selectionChanged", () =>
|
|
215
|
-
if (!props.scrollToBottomOnThreadSwitch) return;
|
|
216
|
-
scheduleScrollToBottom("instant");
|
|
217
|
-
});
|
|
109
|
+
useAuiEvent("threads.selectionChanged", () => autoScroll.threadSwitched());
|
|
218
110
|
|
|
219
111
|
provide(viewportInjectionKey, {
|
|
220
112
|
isAtBottom,
|
|
221
113
|
scrollToBottom: (behavior: ScrollBehavior = "auto") =>
|
|
222
|
-
scrollToBottom(behavior),
|
|
114
|
+
autoScroll.scrollToBottom(behavior),
|
|
223
115
|
registerContentInset,
|
|
224
116
|
});
|
|
225
117
|
|
|
226
|
-
return () =>
|
|
227
|
-
h(
|
|
228
|
-
"div",
|
|
229
|
-
{ ref: divRef, onScroll: handleScroll, onPointerdown },
|
|
230
|
-
slots.default?.(),
|
|
231
|
-
);
|
|
118
|
+
return () => h("div", { ref: divRef }, slots.default?.());
|
|
232
119
|
},
|
|
233
120
|
});
|
|
@@ -20,10 +20,17 @@ import { ThreadPrimitiveViewportFooter } from "./ThreadPrimitiveViewportFooter";
|
|
|
20
20
|
|
|
21
21
|
const geometryElements = new Set<HTMLElement>();
|
|
22
22
|
|
|
23
|
-
const createTestRuntime = () =>
|
|
23
|
+
const createTestRuntime = (withHistory = false) =>
|
|
24
24
|
new AssistantRuntimeImpl(
|
|
25
25
|
new ExternalStoreRuntimeCore({
|
|
26
|
-
messages:
|
|
26
|
+
messages: withHistory
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
role: "user" as const,
|
|
30
|
+
content: [{ type: "text" as const, text: "history" }],
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
: [],
|
|
27
34
|
convertMessage: () => ({ role: "user", content: [] }),
|
|
28
35
|
onNew: async () => {},
|
|
29
36
|
}),
|
|
@@ -84,10 +91,31 @@ const installViewportGeometry = (div: HTMLElement) => {
|
|
|
84
91
|
};
|
|
85
92
|
};
|
|
86
93
|
|
|
94
|
+
const mountChatWithViewportGeometry = (
|
|
95
|
+
runtime: AssistantRuntimeImpl,
|
|
96
|
+
view: Component,
|
|
97
|
+
) => {
|
|
98
|
+
const geometries = new WeakMap<
|
|
99
|
+
HTMLElement,
|
|
100
|
+
ReturnType<typeof installViewportGeometry>
|
|
101
|
+
>();
|
|
102
|
+
const createElement = document.createElement.bind(document);
|
|
103
|
+
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
|
|
104
|
+
const element = createElement(tagName);
|
|
105
|
+
if (tagName === "div") {
|
|
106
|
+
geometries.set(element, installViewportGeometry(element));
|
|
107
|
+
}
|
|
108
|
+
return element;
|
|
109
|
+
});
|
|
110
|
+
const mounted = mountChat(runtime, view);
|
|
111
|
+
const viewport = mounted.el.querySelector<HTMLElement>("div.viewport")!;
|
|
112
|
+
return { ...mounted, viewport, geometry: geometries.get(viewport)! };
|
|
113
|
+
};
|
|
114
|
+
|
|
87
115
|
const installResizeObserver = () => {
|
|
88
116
|
const observers = new Set<ResizeObserverMock>();
|
|
89
117
|
class ResizeObserverMock {
|
|
90
|
-
|
|
118
|
+
readonly elements = new Set<Element>();
|
|
91
119
|
readonly callback: ResizeObserverCallback;
|
|
92
120
|
|
|
93
121
|
constructor(callback: ResizeObserverCallback) {
|
|
@@ -96,12 +124,15 @@ const installResizeObserver = () => {
|
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
observe(element: Element) {
|
|
99
|
-
this.element
|
|
127
|
+
this.elements.add(element);
|
|
100
128
|
}
|
|
101
129
|
|
|
102
|
-
unobserve() {
|
|
130
|
+
unobserve(element: Element) {
|
|
131
|
+
this.elements.delete(element);
|
|
132
|
+
}
|
|
103
133
|
|
|
104
134
|
disconnect() {
|
|
135
|
+
this.elements.clear();
|
|
105
136
|
observers.delete(this);
|
|
106
137
|
}
|
|
107
138
|
|
|
@@ -117,7 +148,7 @@ const installResizeObserver = () => {
|
|
|
117
148
|
return {
|
|
118
149
|
trigger: (element: Element) => {
|
|
119
150
|
for (const observer of observers) {
|
|
120
|
-
if (observer.element
|
|
151
|
+
if (observer.elements.has(element)) observer.trigger();
|
|
121
152
|
}
|
|
122
153
|
},
|
|
123
154
|
};
|
|
@@ -135,6 +166,70 @@ afterEach(() => {
|
|
|
135
166
|
geometryElements.clear();
|
|
136
167
|
});
|
|
137
168
|
|
|
169
|
+
describe("ThreadPrimitiveViewport initial pinning", () => {
|
|
170
|
+
it.each(["viewport", "content"])(
|
|
171
|
+
"follows the first %s resize over overflowing history when initialization scroll is disabled",
|
|
172
|
+
(target) => {
|
|
173
|
+
const observers = installResizeObserver();
|
|
174
|
+
const View = defineComponent({
|
|
175
|
+
setup: () => () =>
|
|
176
|
+
h(
|
|
177
|
+
ThreadPrimitiveViewport,
|
|
178
|
+
{ class: "viewport", scrollToBottomOnInitialize: false },
|
|
179
|
+
{ default: () => h("p", "history") },
|
|
180
|
+
),
|
|
181
|
+
});
|
|
182
|
+
const { viewport, geometry, unmount } = mountChatWithViewportGeometry(
|
|
183
|
+
createTestRuntime(true),
|
|
184
|
+
View,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
observers.trigger(
|
|
188
|
+
target === "viewport" ? viewport : viewport.firstElementChild!,
|
|
189
|
+
);
|
|
190
|
+
expect(geometry.scrollTo).toHaveBeenCalledWith({
|
|
191
|
+
top: 500,
|
|
192
|
+
behavior: "instant",
|
|
193
|
+
});
|
|
194
|
+
expect(viewport.scrollTop).toBe(400);
|
|
195
|
+
unmount();
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
it("follows a footer height reported before the viewport attaches", () => {
|
|
200
|
+
installResizeObserver();
|
|
201
|
+
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
|
|
202
|
+
function (this: HTMLElement) {
|
|
203
|
+
return this.classList.contains("footer") ? 50 : 0;
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
const View = defineComponent({
|
|
207
|
+
setup: () => () =>
|
|
208
|
+
h(
|
|
209
|
+
ThreadPrimitiveViewport,
|
|
210
|
+
{ class: "viewport", scrollToBottomOnInitialize: false },
|
|
211
|
+
{
|
|
212
|
+
default: () => [
|
|
213
|
+
h("p", "history"),
|
|
214
|
+
h(ThreadPrimitiveViewportFooter, { class: "footer" }),
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
),
|
|
218
|
+
});
|
|
219
|
+
const { viewport, geometry, unmount } = mountChatWithViewportGeometry(
|
|
220
|
+
createTestRuntime(true),
|
|
221
|
+
View,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(geometry.scrollTo).toHaveBeenCalledWith({
|
|
225
|
+
top: 500,
|
|
226
|
+
behavior: "instant",
|
|
227
|
+
});
|
|
228
|
+
expect(viewport.scrollTop).toBe(400);
|
|
229
|
+
unmount();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
138
233
|
describe("ThreadPrimitiveViewportFooter", () => {
|
|
139
234
|
it("registers its height and unregisters it when removed", async () => {
|
|
140
235
|
const observers = installResizeObserver();
|