@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.
@@ -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;;;;;;;;;;;;;;0GA8CjC,CAAC"}
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\" || event.shiftKey || event.isComposing) 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,IAAI,MAAM,QAAQ,WAAW,MAAM,YAAY,MAAM,aAAa;GAClE,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"}
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;;;;;;;;;;;;AA0CnE,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"}
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: an edit or reload that replaces the occupant of a
6
- * slot remounts that row, so `<TransitionGroup>` and per-row component state
7
- * follow message identity. The empty optimistic placeholder that precedes a
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;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,uBAAuB;;;cAEK,MAAM,UAAU,EAAE;yEAezD,CAAC"}
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: an edit or reload that replaces the occupant of a
10
- * slot remounts that row, so `<TransitionGroup>` and per-row component state
11
- * follow message identity. The empty optimistic placeholder that precedes a
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: an edit or reload that replaces the occupant of a\n * slot remounts that row, so `<TransitionGroup>` and per-row component state\n * follow message identity. The empty optimistic placeholder that precedes a\n * response is its own identity, so the arrival of the real assistant message\n * remounts that one row (a leave/enter pair under `<TransitionGroup>`).\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":";;;;;;;;;;;;;;;;;;;;;AAsBA,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
+ {"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;AAYb;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAoBK,MAAM,UAAU,EAAE;yEAmLzD,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 { isUserScrollUp, isViewportAtBottom, observeContentResize, viewportOverflows } from "@assistant-ui/store/client";
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
- let lastScrollTop = 0;
46
- let lastScrollHeight = 0;
47
- let lastObservedScrollHeight = 0;
48
- let lastObservedClientHeight = 0;
49
- let frame = null;
50
- const scrollToBottom = (behavior) => {
51
- const div = divRef.value;
52
- if (!div) return;
53
- intent = behavior;
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
- lastScrollTop = div.scrollTop;
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
- if (contentInset.value === total) return;
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
- const onContentResize = () => {
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
- disconnect = observeContentResize(div, onContentResize);
78
+ detach = autoScroll.attach(div);
134
79
  });
135
80
  onScopeDispose(() => {
136
- disconnect?.();
137
- if (frame !== null) cancelAnimationFrame(frame);
81
+ detach?.();
82
+ autoScroll.dispose();
138
83
  });
139
84
  const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
140
- let initialized = false;
141
- watch([hasMessages, () => props.scrollToBottomOnInitialize], ([has, enabled]) => {
142
- if (!has) {
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.1",
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.24",
31
- "@assistant-ui/store": "^0.3.17",
32
- "@assistant-ui/tap": "^0.9.21"
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.3",
40
+ "@types/node": "^26.6.4",
41
41
  "@types/react": "^19.3.0",
42
- "assistant-cloud": "0.2.5",
43
- "jsdom": "^30.0.1",
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 (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
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: an edit or reload that replaces the occupant of a
11
- * slot remounts that row, so `<TransitionGroup>` and per-row component state
12
- * follow message identity. The empty optimistic placeholder that precedes a
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
- let lastScrollTop = 0;
62
- let lastScrollHeight = 0;
63
- let lastObservedScrollHeight = 0;
64
- let lastObservedClientHeight = 0;
65
- let frame: number | null = null;
66
-
67
- const scrollToBottom = (behavior: ScrollBehavior) => {
68
- const div = divRef.value;
69
- if (!div) return;
70
- intent = behavior;
71
- div.scrollTo?.({ top: div.scrollHeight, behavior });
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
- if (contentInset.value === total) return;
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
- const onContentResize = () => {
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
- disconnect = observeContentResize(div, onContentResize);
93
+ detach = autoScroll.attach(div);
186
94
  });
187
95
  onScopeDispose(() => {
188
- disconnect?.();
189
- if (frame !== null) cancelAnimationFrame(frame);
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, enabled]) => {
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
- element: Element | null = null;
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 = 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 === element) observer.trigger();
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();