diff --git a/src/components/xt-chat/Bubble/Bubble.vue b/src/components/xt-chat/Bubble/Bubble.vue
new file mode 100644
index 0000000..3ff5f5a
--- /dev/null
+++ b/src/components/xt-chat/Bubble/Bubble.vue
@@ -0,0 +1,135 @@
+
+
+
diff --git a/src/components/xt-chat/Bubble/BubbleList.vue b/src/components/xt-chat/Bubble/BubbleList.vue
new file mode 100644
index 0000000..51943d4
--- /dev/null
+++ b/src/components/xt-chat/Bubble/BubbleList.vue
@@ -0,0 +1,159 @@
+
+
+
+
+
diff --git a/src/components/xt-chat/Bubble/context.ts b/src/components/xt-chat/Bubble/context.ts
new file mode 100644
index 0000000..99d1f41
--- /dev/null
+++ b/src/components/xt-chat/Bubble/context.ts
@@ -0,0 +1,45 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:17:49
+ */
+import { computed, defineComponent, inject, provide, shallowRef, triggerRef, unref, watch } from "vue";
+import type { ComputedRef, InjectionKey } from "vue";
+import { objectType } from "@/utils/type";
+import type { BubbleContextProps } from "./types";
+
+const BubbleContextKey: InjectionKey> =
+ Symbol('BubbleContext');
+
+export const globalBubbleContextApi = shallowRef();
+
+export const useBubbleContextProvider = (value: ComputedRef) => {
+ provide(BubbleContextKey, value);
+ watch(
+ value,
+ () => {
+ globalBubbleContextApi.value = unref(value);
+ triggerRef(globalBubbleContextApi);
+ },
+ { immediate: true, deep: true },
+ );
+};
+
+export const useBubbleContextInject = () => {
+ return inject(
+ BubbleContextKey,
+ computed(() => globalBubbleContextApi.value || {}),
+ );
+};
+export const BubbleContextProvider = defineComponent({
+ props: {
+ value: objectType(),
+ },
+ setup(props, { slots }) {
+ useBubbleContextProvider(computed(() => props.value));
+ return () => {
+ return slots.default?.();
+ };
+ },
+});
+
+export default BubbleContextProvider;
\ No newline at end of file
diff --git a/src/components/xt-chat/Bubble/hooks/useDisplayData.ts b/src/components/xt-chat/Bubble/hooks/useDisplayData.ts
new file mode 100644
index 0000000..77967f3
--- /dev/null
+++ b/src/components/xt-chat/Bubble/hooks/useDisplayData.ts
@@ -0,0 +1,58 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:12:42
+ */
+import { computed, unref, watch } from 'vue';
+import type { Ref } from 'vue';
+import { useEventCallback } from '@/hooks/useEventCallback';
+import useState from '@/hooks/useState';
+import type { ListItemType } from './useListData';
+
+type UseDisplayDataReturn = [Ref, (value: string | number) => void];
+
+export default function useDisplayData(items: Ref): UseDisplayDataReturn {
+ const [displayCount, setDisplayCount] = useState(items.value.length);
+
+ const displayList = computed(() => items.value.slice(0, unref(displayCount)));
+
+ const displayListLastKey = computed(() => {
+ const lastItem = unref(displayList)[unref(displayList).length - 1];
+ return lastItem ? lastItem.key : null;
+ });
+
+ // When `items` changed, we replaced with latest one
+ watch(
+ items,
+ () => {
+ setDisplayCount(items.value.length);
+ if (
+ unref(displayList).length &&
+ unref(displayList).every((item, index) => item.key === items.value[index]?.key)
+ ) {
+ return;
+ }
+
+ if (unref(displayList).length === 0) {
+ setDisplayCount(1);
+ } else {
+ // Find diff index
+ for (let i = 0; i < unref(displayList).length; i += 1) {
+ if (unref(displayList)[i].key !== items.value[i]?.key) {
+ setDisplayCount(i);
+ break;
+ }
+ }
+ }
+ },
+ { immediate: true, deep: true },
+ );
+
+ // Continue to show if last one finished typing
+ const onTypingComplete = useEventCallback((key: string | number) => {
+ if (key === unref(displayListLastKey)) {
+ setDisplayCount(unref(displayCount) + 1);
+ }
+ });
+
+ return [displayList, onTypingComplete] as const;
+}
diff --git a/src/components/xt-chat/Bubble/hooks/useListData.ts b/src/components/xt-chat/Bubble/hooks/useListData.ts
new file mode 100644
index 0000000..cd0142d
--- /dev/null
+++ b/src/components/xt-chat/Bubble/hooks/useListData.ts
@@ -0,0 +1,41 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:14:55
+ */
+import { computed, type Ref } from 'vue';
+import type { BubbleDataType, BubbleListProps } from '../types';
+import type { BubbleProps } from '../types';
+
+export type UnRef> = T extends Ref ? R : never;
+
+export type ListItemType = UnRef>[number];
+
+export default function useListData(
+ items: Ref,
+ roles?: Ref,
+) {
+ const getRoleBubbleProps = (bubble: BubbleDataType, index: number): Partial => {
+ if (typeof roles.value === 'function') {
+ return roles.value(bubble, index);
+ }
+
+ if (roles) {
+ return roles.value?.[bubble.role!] || {};
+ }
+
+ return {};
+ }
+
+ const listData = computed(() =>
+ (items.value || []).map((bubbleData, i) => {
+ const mergedKey = bubbleData.key ?? `preset_${i}`;
+
+ return {
+ ...getRoleBubbleProps(bubbleData, i),
+ ...bubbleData,
+ key: mergedKey,
+ };
+ }));
+
+ return listData as Ref;
+}
\ No newline at end of file
diff --git a/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts b/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts
new file mode 100644
index 0000000..1cbe4b9
--- /dev/null
+++ b/src/components/xt-chat/Bubble/hooks/useTypedEffect.ts
@@ -0,0 +1,75 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:26:23
+ */
+import useState from '@/hooks/useState';
+import { computed, onWatcherCleanup, unref, watch } from 'vue';
+import type { Ref } from 'vue';
+import type { BubbleContentType } from '../types';
+
+function isString(str: any): str is string {
+ return typeof str === 'string';
+}
+
+/**
+ * Return typed content and typing status when typing is enabled.
+ * Or return content directly.
+ */
+const useTypedEffect = (
+ content: Ref,
+ typingEnabled: Ref,
+ typingStep: Ref,
+ typingInterval: Ref,
+): [typedContent: Ref, isTyping: Ref] => {
+ const [prevContent, setPrevContent] = useState('');
+ const [typingIndex, setTypingIndex] = useState(1);
+
+ const mergedTypingEnabled = computed(() => typingEnabled.value && isString(content.value));
+
+ // Reset typing index when content changed
+ watch(
+ content,
+ () => {
+ const prevContentValue = unref(prevContent);
+ setPrevContent(content.value);
+ if (!mergedTypingEnabled.value && isString(content.value)) {
+ setTypingIndex(content.value.length);
+ } else if (
+ isString(content.value) &&
+ isString(prevContentValue) &&
+ content.value.indexOf(prevContentValue) !== 0
+ ) {
+ setTypingIndex(1);
+ }
+ },
+ { immediate: true },
+ );
+
+ // Start typing
+ watch(
+ [typingIndex, typingEnabled, content],
+ () => {
+ if (mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length) {
+ const id = setTimeout(() => {
+ setTypingIndex(unref(typingIndex) + typingStep.value);
+ }, typingInterval.value);
+
+ onWatcherCleanup(() => {
+ clearTimeout(id);
+ });
+ }
+ },
+ { immediate: true },
+ );
+
+ const mergedTypingContent = computed(() =>
+ mergedTypingEnabled.value && isString(content.value) ? content.value.slice(0, unref(typingIndex)) : content.value,
+ );
+
+ return [
+ mergedTypingContent,
+ computed(() => mergedTypingEnabled.value && isString(content.value) && unref(typingIndex) < content.value.length),
+ ];
+};
+
+export default useTypedEffect;
diff --git a/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts b/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts
new file mode 100644
index 0000000..5ffa015
--- /dev/null
+++ b/src/components/xt-chat/Bubble/hooks/useTypingConfig.ts
@@ -0,0 +1,37 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:27:05
+ */
+import { computed, toValue, type MaybeRefOrGetter } from 'vue';
+import type { BubbleProps, TypingOption } from '../types';
+
+function useTypingConfig(typing: MaybeRefOrGetter) {
+ const typingEnabled = computed(() => {
+ if (!toValue(typing)) {
+ return false;
+ }
+ return true;
+ });
+ const baseConfig: Required = {
+ step: 1,
+ interval: 50,
+ // set default suffix is empty
+ suffix: null,
+ };
+ const config = computed(() => {
+ const typingRaw = toValue(typing);
+ return {
+ ...baseConfig,
+ ...(typeof typingRaw === 'object' ? typingRaw : {})
+ }
+ });
+
+ return [
+ typingEnabled,
+ computed(() => config.value.step),
+ computed(() => config.value.interval),
+ computed(() => config.value.suffix)
+ ] as const;
+}
+
+export default useTypingConfig;
\ No newline at end of file
diff --git a/src/components/xt-chat/Bubble/index.ts b/src/components/xt-chat/Bubble/index.ts
new file mode 100644
index 0000000..d98e559
--- /dev/null
+++ b/src/components/xt-chat/Bubble/index.ts
@@ -0,0 +1,9 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 22:16:14
+ */
+export { default as Bubble } from './Bubble.vue';
+export { default as BubbleList } from './BubbleList.vue';
+export * from './types';
+
+
diff --git a/src/components/xt-chat/Bubble/index.vue b/src/components/xt-chat/Bubble/index.vue
new file mode 100644
index 0000000..452b383
--- /dev/null
+++ b/src/components/xt-chat/Bubble/index.vue
@@ -0,0 +1,4 @@
+
diff --git a/src/components/xt-chat/Bubble/loading.vue b/src/components/xt-chat/Bubble/loading.vue
new file mode 100644
index 0000000..c6e96b8
--- /dev/null
+++ b/src/components/xt-chat/Bubble/loading.vue
@@ -0,0 +1,29 @@
+
+
diff --git a/src/components/xt-chat/Bubble/style.scss b/src/components/xt-chat/Bubble/style.scss
new file mode 100644
index 0000000..ae35d27
--- /dev/null
+++ b/src/components/xt-chat/Bubble/style.scss
@@ -0,0 +1,94 @@
+.xt-bubble-list {
+ gap: 8px;
+ .xt-bubble {
+ display: flex;
+ justify-content: start;
+ margin: 8px 0;
+ &.xt-bubble-start {
+ flex-direction: row;
+ }
+ &.xt-bubble-end {
+ justify-content: end;
+ flex-direction: row-reverse;
+ }
+ .xt-bubble-content {
+ color: var(--Text-1, #211f24);
+ font-family: $font-family-regular;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 22px;
+ .xt-bubble__avatar {
+ margin: 0 8px;
+ img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+ }
+ &.xt-bubble-content-round {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50px;
+ background: var(--BG-200, #f2f3f5);
+ padding-inline: 0;
+ }
+ }
+ }
+}
+
+.xt-bubble--filled .xt-bubble__content {
+ background-color: #f5f5f5;
+}
+
+.xt-bubble--outlined .xt-bubble__content {
+ background-color: #fff;
+ border: 1px solid #e5e6eb;
+}
+
+.xt-bubble--shadow .xt-bubble__content {
+ background-color: #fff;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.xt-bubble--borderless .xt-bubble__content {
+ background-color: transparent;
+}
+
+.xt-bubble--shape-default .xt-bubble__content {
+ border-radius: 10px;
+}
+
+.xt-bubble--shape-round .xt-bubble__content {
+ border-radius: 18px;
+}
+
+.xt-bubble--shape-corner .xt-bubble__content {
+ border-radius: 4px;
+}
+
+.xt-bubble__header {
+ font-size: 12px;
+ color: #86909c;
+ margin-bottom: 6px;
+}
+
+.xt-bubble__footer {
+ font-size: 12px;
+ color: #86909c;
+ margin-top: 6px;
+}
+
+.xt-bubble__typing {
+ display: inline-block;
+ min-width: 36px;
+}
+
+.xt-bubble-list {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
diff --git a/src/components/xt-chat/Bubble/types.ts b/src/components/xt-chat/Bubble/types.ts
new file mode 100644
index 0000000..0bbc1c4
--- /dev/null
+++ b/src/components/xt-chat/Bubble/types.ts
@@ -0,0 +1,90 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 22:04:54
+ */
+import type { AvatarProps } from 'ant-design-vue';
+import type { CSSProperties, HTMLAttributes, VNode } from 'vue';
+export type AvoidValidation = T;
+
+export interface TypingOption {
+ /**
+ * @default 1
+ */
+ step?: number;
+ /**
+ * @default 50
+ */
+ interval?: number;
+ /**
+ * @default null
+ */
+ suffix?: VNode | string;
+}
+
+export type SemanticType = 'avatar' | 'content' | 'header' | 'footer';
+
+export type BubbleContentType = VNode | string | Record | number;
+
+export type SlotInfoType = {
+ key?: string | number;
+};
+
+export interface _AvatarProps extends AvatarProps {
+ class: string;
+ style: CSSProperties;
+}
+
+export interface BubbleProps extends /* @vue-ignore */ Omit {
+ prefixCls?: string;
+ rootClassName?: string;
+ styles?: Partial>;
+ classNames?: Partial>;
+ avatar?: Partial<_AvatarProps> | VNode | (() => VNode);
+ placement?: 'start' | 'end';
+ loading?: boolean;
+ typing?: AvoidValidation;
+ content?: ContentType;
+ messageRender?: (content: ContentType) => VNode | string;
+ loadingRender?: () => VNode;
+ variant?: 'filled' | 'borderless' | 'outlined' | 'shadow';
+ shape?: 'round' | 'corner';
+ _key?: number | string;
+ onTypingComplete?: VoidFunction;
+ header?: AvoidValidation VNode | string)>;
+ footer?: AvoidValidation VNode | string)>;
+}
+
+export interface BubbleRef {
+ nativeElement: HTMLElement;
+}
+
+export interface BubbleContextProps {
+ onUpdate?: VoidFunction;
+}
+
+export interface BubbleListRef {
+ nativeElement: HTMLDivElement;
+ scrollTo: (info: {
+ offset?: number;
+ key?: string | number;
+ behavior?: ScrollBehavior;
+ block?: ScrollLogicalPosition;
+ }) => void;
+}
+
+export type BubbleDataType = BubbleProps & {
+ key?: string | number;
+ role?: string;
+};
+
+export type RoleType = Partial, 'content'>>;
+
+export type RolesType = Record | ((bubbleDataP: BubbleDataType, index: number) => RoleType);
+
+export interface BubbleListProps extends /* @vue-ignore */ HTMLAttributes {
+ prefixCls?: string;
+ rootClassName?: string;
+ items?: BubbleDataType[];
+ autoScroll?: boolean;
+ roles?: AvoidValidation;
+}
\ No newline at end of file
diff --git a/src/hooks/useEventCallback.ts b/src/hooks/useEventCallback.ts
new file mode 100644
index 0000000..1e21f73
--- /dev/null
+++ b/src/hooks/useEventCallback.ts
@@ -0,0 +1,15 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:12:14
+ */
+import { ref } from 'vue';
+
+export function useEventCallback(handler?: (value: T) => void): (value: T) => void {
+ const callbackRef = ref(handler);
+ const fn = ref((value: T) => {
+ callbackRef.value && callbackRef.value(value);
+ });
+ callbackRef.value = handler;
+
+ return fn.value;
+}
\ No newline at end of file
diff --git a/src/hooks/useState.ts b/src/hooks/useState.ts
new file mode 100644
index 0000000..7393c43
--- /dev/null
+++ b/src/hooks/useState.ts
@@ -0,0 +1,21 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:14:22
+ */
+import type { Ref } from 'vue';
+import { ref } from 'vue';
+
+export default function useState>(
+ defaultStateValue?: T | (() => T),
+): [R, (val: T) => void] {
+ const initValue: T =
+ typeof defaultStateValue === 'function' ? (defaultStateValue as any)() : defaultStateValue;
+
+ const innerValue = ref(initValue) as Ref;
+
+ function triggerChange(newValue: T) {
+ innerValue.value = newValue;
+ }
+
+ return [innerValue as unknown as R, triggerChange];
+}
\ No newline at end of file
diff --git a/src/utils/pick-attrs.ts b/src/utils/pick-attrs.ts
new file mode 100644
index 0000000..bf87de6
--- /dev/null
+++ b/src/utils/pick-attrs.ts
@@ -0,0 +1,80 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:43:17
+ */
+const attributes = `accept acceptcharset accesskey action allowfullscreen allowtransparency
+alt async autocomplete autofocus autoplay capture cellpadding cellspacing challenge
+charset checked classid class colspan cols content contenteditable contextmenu
+controls coords crossorigin data datetime default defer dir disabled download draggable
+enctype form formaction formenctype formmethod formnovalidate formtarget frameborder
+headers height hidden high href hreflang htmlfor for httpequiv icon id inputmode integrity
+is keyparams keytype kind label lang list loop low manifest marginheight marginwidth max maxlength media
+mediagroup method min minlength multiple muted name novalidate nonce open
+optimum pattern placeholder poster preload radiogroup readonly rel required
+reversed role rowspan rows sandbox scope scoped scrolling seamless selected
+shape size sizes span spellcheck src srcdoc srclang srcset start step style
+summary tabindex target title type usemap value width wmode wrap`;
+
+const eventsName = `onCopy onCut onPaste onCompositionend onCompositionstart onCompositionupdate onKeydown
+ onKeypress onKeyup onFocus onBlur onChange onInput onSubmit onClick onContextmenu onDoubleclick onDblclick
+ onDrag onDragend onDragenter onDragexit onDragleave onDragover onDragstart onDrop onMousedown
+ onMouseenter onMouseleave onMousemove onMouseout onMouseover onMouseup onSelect onTouchcancel
+ onTouchend onTouchmove onTouchstart onTouchstartPassive onTouchmovePassive onScroll onWheel onAbort onCanplay onCanplaythrough
+ onDurationchange onEmptied onEncrypted onEnded onError onLoadeddata onLoadedmetadata
+ onLoadstart onPause onPlay onPlaying onProgress onRatechange onSeeked onSeeking onStalled onSuspend onTimeupdate onVolumechange onWaiting onLoad onError`;
+
+const propList = `${attributes} ${eventsName}`.split(/[\s\n]+/);
+
+/* eslint-enable max-len */
+const ariaPrefix = 'aria-';
+const dataPrefix = 'data-';
+
+function match(key: string, prefix: string) {
+ return key.indexOf(prefix) === 0;
+}
+
+export interface PickConfig {
+ aria?: boolean;
+ data?: boolean;
+ attr?: boolean;
+}
+
+/**
+ * Picker props from exist props with filter
+ * @param props Passed props
+ * @param ariaOnly boolean | { aria?: boolean; data?: boolean; attr?: boolean; } filter config
+ */
+export default function pickAttrs(props: object, ariaOnly: boolean | PickConfig = false) {
+ let mergedConfig;
+ if (ariaOnly === false) {
+ mergedConfig = {
+ aria: true,
+ data: true,
+ attr: true,
+ };
+ } else if (ariaOnly === true) {
+ mergedConfig = {
+ aria: true,
+ };
+ } else {
+ mergedConfig = {
+ ...ariaOnly,
+ };
+ }
+
+ const attrs = {};
+ Object.keys(props).forEach((key) => {
+ if (
+ // Aria
+ (mergedConfig.aria && (key === 'role' || match(key, ariaPrefix))) ||
+ // Data
+ (mergedConfig.data && match(key, dataPrefix)) ||
+ // Attr
+ (mergedConfig.attr && (propList.includes(key) || propList.includes(key.toLowerCase())))
+ ) {
+ // @ts-ignore
+ attrs[key] = props[key];
+ }
+ });
+ return attrs;
+}
diff --git a/src/utils/type.ts b/src/utils/type.ts
new file mode 100644
index 0000000..3dc49dd
--- /dev/null
+++ b/src/utils/type.ts
@@ -0,0 +1,13 @@
+/*
+ * @Author: RenXiaoDong
+ * @Date: 2025-08-20 23:18:36
+ */
+import type { PropType, VNode } from 'vue';
+
+declare type VNodeChildAtom = VNode | string | number | boolean | null | undefined | void;
+
+export type VueNode = VNodeChildAtom | VNodeChildAtom[] | VNode;
+
+export function objectType(defaultVal?: T) {
+ return { type: Object as PropType, default: defaultVal as T };
+}
diff --git a/src/views/home/components/conversation-detail/index.vue b/src/views/home/components/conversation-detail/index.vue
index be92259..46c761d 100644
--- a/src/views/home/components/conversation-detail/index.vue
+++ b/src/views/home/components/conversation-detail/index.vue
@@ -1,6 +1,6 @@