Skip to content

Conversation

@SonyLeo
Copy link
Collaborator

@SonyLeo SonyLeo commented Jan 7, 2026

Sender 组件重构

📋 概述

本次 PR 对 packages/components/src/sender 目录进行了大规模重构,从基于自定义编辑器的组件架构重构为基于 Tiptap 编辑器的现代化架构。重构涉及核心组件重写、扩展系统构建、类型系统优化、操作按钮模块化以及新增多项功能特性。


🎯 主要变更

1. 核心组件重构

1.1 Sender.vue - 架构重构

变更前:

  • 基于自定义 contenteditable 实现的编辑器
  • 组件内部包含大量业务逻辑(~876 行)
  • 直接在组件中处理键盘事件、输入处理、建议系统
  • 使用多个独立的 composables(useInputHandler、useKeyboardHandler、useSuggestionHandler 等)
  • 模板和操作按钮耦合在主组件中

变更后:

  • 基于 Tiptap 编辑器的专业级编辑能力
  • 组件精简为布局容器(~100 行)
  • 通过 useSenderCore 统一管理所有逻辑
  • 采用 Tiptap 扩展系统处理高级功能
  • 布局和逻辑完全分离,通过 Context 传递状态

关键代码变更:

<!-- 新架构:精简的组件结构 -->
<script setup lang="ts">
import { useSenderCore } from './composables/useSenderCore'
import SingleLineLayout from './components/layouts/SingleLineLayout.vue'
import MultiLineLayout from './components/layouts/MultiLineLayout.vue'

const props = withDefaults(defineProps<SenderProps>(), { /* ... */ })
const emit = defineEmits<SenderEmits>()

// 核心逻辑一键引入
const { context, expose } = useSenderCore(props, emit)

// 暴露方法给父组件
defineExpose(expose)
</script>

<template>
  <div class="tr-sender">
    <!-- 布局分发 -->
    <SingleLineLayout v-if="context.mode.value === 'single'">
      <!-- 透传所有插槽 -->
    </SingleLineLayout>
    <MultiLineLayout v-else>
      <!-- 透传所有插槽 -->
    </MultiLineLayout>
  </div>
</template>

1.2 新增布局组件

SingleLineLayout.vue

  • 单行模式布局
  • 支持 prefix、content、actions-inline 插槽
  • 水平排列的紧凑布局

MultiLineLayout.vue

  • 多行模式布局
  • 支持 header、prefix、content、footer、footer-right 插槽
  • 垂直排列的扩展布局

EditorContent/index.vue

  • 封装 Tiptap 的 EditorContent 组件
  • 处理编辑器内容渲染
  • 统一编辑器样式

Footer/index.vue

  • 底部操作栏组件
  • 支持左右两侧插槽
  • 提供默认操作按钮

2. Composables 系统重构

2.1 useSenderCore.ts - 核心逻辑聚合器

职责:

  • 统一管理所有 Hook 的初始化顺序
  • 解决循环依赖问题
  • 自动组装 Context 和 Expose
  • 作为逻辑层与视图层的桥梁

提供的能力:

export interface UseSenderCoreReturn {
  context: SenderContext  // 通过 provide 注入的上下文
  expose: {               // 暴露给父组件的方法
    submit: () => void
    clear: () => void
    cancel: () => void
    focus: () => void
    blur: () => void
    setContent: (content: string) => void
    getContent: () => string
    editor: Ref<Editor | null>
  }
}

2.2 新增 Composables

useEditor.ts

  • 初始化 Tiptap 编辑器实例
  • 配置编辑器扩展(Document、Paragraph、Text、History、Placeholder、CharacterCount)
  • 处理编辑器事件(update、focus、blur)
  • 管理编辑器生命周期

useKeyboardShortcuts.ts

  • 统一管理键盘快捷键
  • 支持 Enter、Shift+Enter、Cmd/Ctrl+Enter 等组合键
  • 根据 submitType 配置触发提交或换行
  • 处理模式切换快捷键

useModeSwitch.ts

  • 管理单行/多行模式切换
  • 支持自动切换(autoSwitch)
  • 处理内容溢出检测
  • 提供平滑的切换动画

useAutoSize.ts

  • 实现编辑器自动高度调整
  • 支持 minRows 和 maxRows 配置
  • 基于内容动态计算高度
  • 优化性能,避免频繁重排

useSlotScope.ts

  • 管理插槽作用域数据
  • 提供统一的插槽 props
  • 简化插槽使用

2.3 删除的 Composables

  • useInputHandler.ts - 输入处理逻辑(由 Tiptap 接管)
  • useKeyboardHandler.ts - 键盘处理逻辑(重构为 useKeyboardShortcuts)
  • useSuggestionHandler.ts - 建议处理逻辑(重构为 Suggestion 扩展)
  • useUndoRedo.ts - 撤销重做逻辑(由 Tiptap History 扩展接管)
  • useSpeechHandler.ts - 语音处理逻辑(移至 sender-actions)
  • webSpeechHandler.ts - Web Speech API 处理(移至 sender-actions)

3. Tiptap 扩展系统

新增三大扩展模块,基于 Tiptap 的 ProseMirror 插件系统实现:

扩展 功能 核心文件
Mention @提及功能 plugin.ts (352行)、mention-list.vue (191行)
Suggestion /命令建议 plugin.ts (506行)、suggestion-list.vue
Template 模板系统 block/ 和 select/ 两个子扩展

主要特性:

  • 支持自定义触发字符和数据源
  • 完整的键盘导航支持
  • 异步数据加载和搜索过滤
  • 可配置的渲染和样式
  • Template 支持占位符、单选/多选等高级功能

4. 操作按钮模块化 - sender-actions

4.1 结构说明

设计理念:

  • 将操作按钮从 Sender 组件中解耦
  • 每个按钮都是独立的组件,可单独使用
  • 统一的类型定义和工具函数
  • 支持自定义和扩展

目录结构:

sender-actions/
├── index.ts                    # 导出入口
├── index.type.ts               # 类型定义
├── action-button/              # 基础按钮组件
│   └── index.vue              
├── submit-button/              # 提交按钮
│   └── index.vue               
├── clear-button/               # 清空按钮
│   └── index.vue              
├── upload-button/              # 上传按钮
│   ├── index.vue               
│   └── index.type.ts          
├── voice-button/               # 语音输入按钮
│   ├── index.vue               
│   ├── index.type.ts           
│   ├── speech.types.ts        
│   ├── useSpeechHandler.ts    
│   └── webSpeechHandler.ts     
├── word-counter/               # 字数统计
│   └── index.vue               
├── default-actions/            # 默认按钮组合
│   └── index.vue              
├── types/                      # 类型定义
│   ├── common.ts               
│   ├── index.ts                
│   └── tooltip.ts              
└── utils/                      # 工具函数
    └── tooltip.ts             

4.2 组件说明

组件 功能 特性
ActionButton 基础按钮 统一样式、icon/tooltip 支持
SubmitButton 提交按钮 加载/禁用状态、快捷键提示
ClearButton 清空按钮 清空内容、确认提示
UploadButton 上传按钮 文件/拖拽上传、类型/大小限制
VoiceButton 语音输入 Web Speech API、实时转文字
WordCounter 字数统计 实时统计、超限提示
DefaultActionButtons 默认组合 组合常用按钮、可配置

5. 兼容层 - sender-compat

5.1 新增 sender-compat 组件

目的:

  • 保持向后兼容,避免破坏性变更
  • 提供旧版 API 到新版 API 的适配层
  • 方便用户平滑迁移

文件结构:

sender-compat/
├── index.ts              # 导出入口
├── index.type.ts         # 类型定义(158 行)
└── index.vue             # 兼容组件(294 行)

功能:

  • 支持旧版 props 和 events
  • 自动转换为新版 API
  • 保持旧版行为和样式
  • 提供迁移提示(开发模式)

6. Context 系统

6.1 新增 Context 管理

通过 provide/inject 机制统一管理组件状态和方法,避免 props drilling。

核心能力:

  • 编辑器实例和引用管理
  • 状态管理(mode、size、disabled、loading 等)
  • 计算属性(hasContent、characterCount、canSubmit 等)
  • 方法暴露(submit、clear、focus、setContent 等)
  • 插槽作用域数据

优势:

  • 统一的状态管理
  • 组件间通信简化
  • 更好的类型推导
  • 避免 props 层层传递

7. 类型系统重构

7.1 新增类型文件

将类型定义集中管理,提供完善的 TypeScript 支持(750+ 行类型代码):

  • base.ts (264 行) - 基础类型定义
  • components.ts (84 行) - 组件 Props/Emits 类型
  • composables.ts (146 行) - Composables 返回类型
  • context.ts (170 行) - Context 接口定义
  • slots.ts (86 行) - 插槽类型定义

7.2 主要变更

新增 Props:

  • extensions - Tiptap 扩展配置
  • mode / autoSwitch - 模式配置
  • autoSize - 自动高度配置
  • submitType - 提交方式配置
  • defaultActions - 操作按钮配置

删除:

  • editor.type.ts - 旧的编辑器类型(由 Tiptap 类型替代)

8. 删除的组件文件

8.1 旧版组件清理

ActionButtons.vue (419 行) - 已删除

  • 旧的操作按钮组件
  • 功能已拆分到 sender-actions/ 目录下的独立组件
  • 耦合度高,难以单独使用

Block.vue (58 行) - 已删除

  • 旧的模板块组件
  • 功能已重构为 Template Block 扩展
  • 基于 Tiptap 的 NodeView 实现

TemplateEditor.vue (1129 行) - 已删除

  • 旧的模板编辑器组件
  • 功能已重构为 Template 扩展系统
  • 包含 Template Block 和 Template Select 两个子扩展

SuggestionList.vue - 已移动

  • sender/components/ 移动到 sender/extensions/suggestion/
  • 重构为 suggestion-list.vue (R059 - 59% 相似度)
  • 与 Suggestion 扩展紧密集成

global.d.ts (11 行) - 已删除

  • 旧的全局类型定义
  • 类型定义已迁移到 types/ 目录

9. 样式文件变更

9.1 Sender 样式重构

sender/index.less - 大幅精简

  • 删除:~520 行样式代码
  • 移除冗余和重复的样式
  • 移除旧版编辑器相关样式
  • 保留核心布局样式

新增:styles/components/sender.less (69 行)

  • 提取公共样式到独立文件
  • 使用 CSS 变量实现主题化
  • 更清晰的样式组织结构
  • 支持不同尺寸和模式的样式

9.2 CSS 变量系统

styles/variables.css - 新增 120 行 CSS 变量

  • Sender 组件主题变量(颜色、尺寸、间距、圆角等)
  • 支持主题定制和深色模式
  • 提高样式可维护性

示例变量:

--tr-sender-bg-color: #ffffff;
--tr-sender-border-radius: 8px;
--tr-sender-padding-normal: 12px 16px;
--tr-sender-font-size-normal: 14px;

9.3 样式导入

styles/components/index.css - 更新

  • 新增 @import './sender.less';
  • 统一管理组件样式导入

10. 依赖变更

10.1 新增 Tiptap 依赖(peerDependencies)

新增 9 个 Tiptap 相关依赖(版本 ^3.11.0):

  • @tiptap/core - 核心库
  • @tiptap/vue-3 - Vue 3 集成
  • @tiptap/pm - ProseMirror 核心
  • @tiptap/extension-* - 6 个基础扩展(document、paragraph、text、history、placeholder、character-count)

10.2 新增其他依赖

  • @floating-ui/dom (^1.6.0) - 用于浮动定位(下拉菜单、提示等)

为什么使用 peerDependencies?
避免重复安装、让使用者控制版本、减小包体积、避免版本冲突


11. 导出 API 变更

11.1 新增导出

  • Sender 组件 - TrSender 核心组件及便捷方法
  • Sender Actions 组件 - 所有操作按钮组件(SubmitButtonClearButtonVoiceButtonUploadButtonWordCounter 等)
  • Sender Compat - TrSenderCompat 兼容层组件
  • Sender 扩展 - 通过 TrSender.MentionTrSender.SuggestionTrSender.Template 访问
  • Sender Types - 完整的类型定义

使用示例:

// 导入核心组件
import { TrSender } from '@opentiny/tiny-robot'

// 导入独立按钮组件
import { VoiceButton, UploadButton } from '@opentiny/tiny-robot'

// 导入兼容层组件
import { TrSenderCompat } from '@opentiny/tiny-robot'

// 使用扩展(便捷函数)
const extensions = [
  TrSender.mention(mentions, '@'),
  TrSender.suggestion(suggestions),
  TrSender.template(templates)
]

// 使用扩展(标准配置)
const extensions = [
  TrSender.Mention.configure({ items: mentions, char: '@' }),
  TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter }),
  TrSender.Template.configure({ items: templates })
]

12. 构建配置变更

vite.config.ts - 更新外部依赖配置

  • 新增 Tiptap 相关包到 external 列表
  • 确保 peerDependencies 不被打包
  • 优化构建输出

🔄 破坏性变更

本次重构通过 sender-compat 兼容层确保了向后兼容,
现有用户修改少量代码即可升级至新架构。

可以参考文档了解具体变化👉 sender_v0.4.0

📖 迁移指南

sender-compat 是为 v0.3.0 用户提供的过渡期兼容组件。

它保留了 v0.3.0 的大部分 API,让你:

✅ 快速升级到 v0.4.0 的底层实现
✅ 保持现有代码最小改动
✅ 为将来完全升级到 v0.4.0 Sender 做准备

推荐迁移路径: v0.3.0 Sender → SenderCompat (快速) → v0.4.0 Sender (最终)

3. API 对比表

Props 对比

功能 v0.3.0 v0.4.0 说明
基础属性
绑定值 v-model v-model ✅ 相同
占位符 placeholder placeholder ✅ 相同
禁用/加载 disabled / loading disabled / loading ✅ 相同
字数限制 maxLength / showWordLimit maxLength / showWordLimit ✅ 相同
输入模式 mode mode ✅ 相同
提交方式 submitType submitType ✅ 相同
新增功能
组件尺寸 size ✨ 新增 normal/small
自动高度 autoSize ✨ 新增
默认按钮配置 defaultActions ✨ 新增
扩展系统 extensions ✨ 新增 Tiptap 扩展
已移除
语音输入 allowSpeech / speech ⚠️ 改用 VoiceButton 组件
文件上传 allowFiles ⚠️ 改用 UploadButton 组件
按钮配置 buttonGroup ⚠️ 改用 defaultActions + 插槽
主题配置 theme ⚠️ 改用 ThemeProvider
智能联想 suggestions / suggestionPopupWidth ⚠️ 改用 Suggestion 扩展
模板数据 templateData ⚠️ 改用 Template 扩展

Slots 对比

插槽 v0.3.0 v0.4.0 说明
头部区域 #header #header ✅ 相同
前缀区域 #prefix #prefix ✅ 相同
底部左侧 #footer-left #footer ⚠️ 插槽名变更
底部右侧 #footer-right #footer-right ✅ 相同
单行操作区 #actions #actions-inline ⚠️ 插槽名变更
自定义内容 #decorativeContent #content (需配合 disabled) ⚠️ 用法变更

Events 对比

事件 v0.3.0 v0.4.0 说明
内容更新 @update:modelValue @update:modelValue ✅ 相同
提交内容 @submit @submit ✅ 相同,新增结构化数据参数
清空内容 @clear @clear ✅ 相同
焦点事件 @focus / @blur @focus / @blur ✅ 相同
取消操作 @cancel ✨ 新增
值变化且失焦 @change ⚠️改用 @input
文件选择 @files-selected ⚠️ 改用 UploadButton 的 @select
语音事件 @speech-* ⚠️ 改用 VoiceButton 的事件
联想选择 @suggestion-select ⚠️ 改用 Suggestion 扩展的 onSelect

Methods 对比

方法 v0.3.0 v0.4.0 说明
焦点控制 focus() / blur() focus() / blur() ✅ 相同
清空内容 clear() clear() ✅ 相同
手动提交 submit() submit() ✅ 相同
内容操作 setContent() / getContent() ✨ 新增
取消操作 cancel() ✨ 新增
语音控制 startSpeech() / stopSpeech() ⚠️ 改用 VoiceButton 方法
模板激活 activateTemplateFirstField() ⚠️ 自动处理,无需调用

📁 完整目录结构

重构后的目录结构

packages/components/src/
├── sender/                                    # 核心 Sender 组件
│   ├── index.vue                              # 主组件(精简到 ~100 行)
│   ├── index.ts                               # 导出入口
│   ├── index.type.ts                          # Props/Emits 类型定义
│   ├── index.less                             # 样式文件(精简)
│   │
│   ├── components/                            # UI 组件
│   │   ├── editor-content/
│   │   │   └── index.vue                      # 编辑器内容组件
│   │   ├── footer/
│   │   │   └── index.vue                      # 底部操作栏
│   │   └── layouts/
│   │       ├── SingleLineLayout.vue           # 单行布局
│   │       └── MultiLineLayout.vue            # 多行布局
│   │
│   ├── composables/                           # 逻辑复用
│   │   ├── index.ts                           # 导出入口
│   │   ├── useSenderCore.ts                   # 核心逻辑聚合器(306 行)
│   │   ├── useEditor.ts                       # 编辑器初始化(131 行)
│   │   ├── useKeyboardShortcuts.ts            # 键盘快捷键(78 行)
│   │   ├── useModeSwitch.ts                   # 模式切换(99 行)
│   │   ├── useAutoSize.ts                     # 自动高度(97 行)
│   │   └── useSlotScope.ts                    # 插槽作用域(52 行)
│   │
│   ├── extensions/                            # Tiptap 扩展系统
│   │   ├── index.ts                           # 导出入口
│   │   │
│   │   ├── mention/                           # @提及扩展
│   │   │   ├── index.ts
│   │   │   ├── extension.ts                   # 扩展定义(143 行)
│   │   │   ├── plugin.ts                      # ProseMirror 插件(352 行)
│   │   │   ├── commands.ts                    # 命令定义(45 行)
│   │   │   ├── types.ts                       # 类型定义(152 行)
│   │   │   ├── utils.ts                       # 工具函数(110 行)
│   │   │   ├── index.less                     # 样式
│   │   │   └── components/
│   │   │       ├── mention-list.vue           # 提及列表(191 行)
│   │   │       └── mention-view.vue           # 提及视图(48 行)
│   │   │
│   │   ├── suggestion/                        # 智能建议扩展
│   │   │   ├── index.ts
│   │   │   ├── extension.ts                   # 扩展定义(52 行)
│   │   │   ├── plugin.ts                      # ProseMirror 插件(506 行)
│   │   │   ├── types.ts                       # 类型定义(269 行)
│   │   │   ├── index.less                     # 样式
│   │   │   ├── suggestion-list.vue            # 建议列表(重构)
│   │   │   └── utils/
│   │   │       ├── filter.ts                  # 过滤工具(57 行)
│   │   │       └── highlight.ts               # 高亮工具(重构)
│   │   │
│   │   ├── template/                          # 模板系统扩展
│   │   │   ├── index.ts
│   │   │   ├── extension.ts                   # 扩展定义(25 行)
│   │   │   ├── commands.ts                    # 命令定义(174 行)
│   │   │   ├── types.ts                       # 类型定义(141 行)
│   │   │   ├── utils.ts                       # 工具函数(71 行)
│   │   │   ├── block/                         # 模板块
│   │   │   │   ├── extension.ts               # Block 扩展(104 行)
│   │   │   │   ├── plugins.ts                 # Block 插件(651 行)
│   │   │   │   └── template-block-view.vue    # Block 视图(77 行)
│   │   │   └── select/                        # 模板选择器
│   │   │       ├── extension.ts               # Select 扩展(102 行)
│   │   │       ├── plugins.ts                 # Select 插件(248 行)
│   │   │       ├── dropdown-manager.ts        # 下拉管理器(68 行)
│   │   │       └── template-select-view.vue   # Select 视图(390 行)
│   │   │
│   │   └── utils/                             # 扩展工具
│   │       ├── index.ts
│   │       ├── id-generator.ts                # ID 生成器(19 行)
│   │       └── position.ts                    # 位置计算(65 行)
│   │
│   ├── context/                               # Context 系统
│   │   ├── index.ts                           # 导出和注入键
│   │   └── types.ts                           # Context 类型
│   │
│   └── types/                                 # 类型定义
│       ├── base.ts                            # 基础类型(264 行)
│       ├── components.ts                      # 组件类型(84 行)
│       ├── composables.ts                     # Composables 类型(146 行)
│       ├── context.ts                         # Context 类型(170 行)
│       └── slots.ts                           # 插槽类型(86 行)
│
├── sender-actions/                            # 操作按钮组件(独立)
│   ├── index.ts                               # 导出入口
│   ├── index.type.ts                          # 类型定义
│   │
│   ├── action-button/                         # 基础按钮
│   │   └── index.vue                          # (82 行)
│   ├── submit-button/                         # 提交按钮
│   │   └── index.vue                          # (156 行)
│   ├── clear-button/                          # 清空按钮
│   │   └── index.vue                          # (53 行)
│   ├── upload-button/                         # 上传按钮
│   │   ├── index.vue                          # (82 行)
│   │   └── index.type.ts                      # (69 行)
│   ├── voice-button/                          # 语音按钮
│   │   ├── index.vue                          # (114 行)
│   │   ├── index.type.ts                      # (51 行)
│   │   ├── speech.types.ts                    # (55 行)
│   │   ├── useSpeechHandler.ts                # 语音处理
│   │   └── webSpeechHandler.ts                # Web Speech API
│   ├── word-counter/                          # 字数统计
│   │   └── index.vue                          # (29 行)
│   ├── default-actions/                       # 默认按钮组合
│   │   └── index.vue                          # (54 行)
│   │
│   ├── types/                                 # 类型定义
│   │   ├── common.ts                          # (61 行)
│   │   ├── index.ts                           # (6 行)
│   │   └── tooltip.ts                         # (8 行)
│   │
│   └── utils/                                 # 工具函数
│       └── tooltip.ts                         # (14 行)
│
└── sender-compat/                             # 兼容层
    ├── index.ts                               # 导出入口
    ├── index.type.ts                          # 类型定义(158 行)
    └── index.vue                              # 兼容组件(294 行)

文件变更统计

新增文件(60+)

  • sender/ 目录:components (4个)、composables (6个)、context (2个)、extensions (30+个)、types (5个)
  • sender-actions/ 目录:18 个文件(按钮组件、类型、工具)
  • sender-compat/ 目录:3 个文件(兼容层)
  • 样式文件:sender.less、variables.css (120行)

删除文件(10+)

  • ActionButtons.vue (419行)、Block.vue (58行)、TemplateEditor.vue (1129行)
  • useInputHandler.ts (74行)、useKeyboardHandler.ts (193行)、useSuggestionHandler.ts (183行)、useUndoRedo.ts (55行)
  • editor.type.ts (58行)、global.d.ts (11行)

修改文件(7个)

  • sender/index.vue (~876行 → ~100行)
  • sender/index.ts、index.type.ts、index.less
  • package.json、vite.config.ts、src/index.ts

移动/重命名文件(4个)

  • useSpeechHandler.ts / webSpeechHandler.ts → sender-actions/voice-button/
  • SuggestionList.vue / suggestionHighlight.ts → sender/extensions/suggestion/

代码行数统计

类别 行数 说明
新增代码 8,424 包含所有新增文件
删除代码 3,702 包含所有删除文件和精简代码
净增代码 4,722 新增 - 删除
文件变更 91 新增、删除、修改、移动的文件总数

主要新增代码分布:

  • Tiptap 扩展系统:~3,500 行
  • sender-actions 组件:~900 行
  • sender-compat 兼容层:~450 行
  • Composables 系统:~850 行
  • 类型定义:~750 行
  • 其他(组件、样式等):~1,974 行

主要删除代码:

  • TemplateEditor.vue:1,129 行
  • 样式精简:~520 行
  • ActionButtons.vue:419 行
  • 旧版 Composables:~505 行
  • 其他:~1,129 行

构建体积对比

重构前
image

重构后
image


✅ 总结

本次重构是 Sender 组件的一次全面升级,主要成果包括:

  1. 架构现代化:从自定义编辑器迁移到 Tiptap,获得专业级编辑能力
  2. 模块化设计:操作按钮独立、扩展系统完善、职责清晰
  3. 类型系统完善:750+ 行类型定义,提供完整的 TypeScript 支持
  4. 功能增强:新增 Mention、Suggestion、Template 等扩展功能
  5. 向后兼容:通过 sender-compat 确保迁移成本变小
  6. 开发体验提升:更清晰的代码组织、更好的可维护性、更易扩展

重构规模:

  • 91 个文件变更
  • 净增 4,722 行高质量代码
  • 删除 3,702 行旧代码
  • 主组件从 ~876 行精简到 ~100 行

感谢 Reviewers 的审查! 🙏

Summary by CodeRabbit

  • New Features

    • Rich editor: single/multi-line layouts, mentions, smart suggestions/autocomplete, template blocks & selects, structured-data export.
    • Sender actions: Submit (with cancel), Clear, Upload (file selection + validation), Voice dictation (start/stop + events), live word/character counter.
    • SenderCompat wrapper for legacy integration.
  • Refactor

    • Sender core modularized into composables with a unified, extensible public API for plugins and extensions.
  • Style

    • New theming tokens and compact "small" size variant.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 7, 2026

Walkthrough

Refactors Sender into a composable-driven TipTap editor, adds Mention/Suggestion/Template extensions, adds sender-action components (action button, submit, clear, upload, voice, word counter), provides SenderCompat shim, many new composables/types/styles, and removes legacy editor components and hooks.

Changes

Cohort / File(s) Summary
Dependencies & Build
packages/components/package.json, packages/components/vite.config.ts
Added TipTap packages to peerDependencies, added @floating-ui/dom to dependencies, and marked TipTap packages / @tiptap/pm as externals.
Public exports & entry
packages/components/src/index.ts, packages/components/src/sender/index.ts
Exported sender-actions barrel and types, useSenderContext, Sender composite with install and static extensions (Mention/Suggestion/Template), and exposed SenderCompat.
Sender public types
packages/components/src/sender/index.type.ts, packages/components/src/sender/types/*
Reorganized and expanded Sender public types and composable return types; introduced TemplateItem, StructuredData, DefaultActions, slot/context types, and composable interfaces.
Core composables & context
packages/components/src/sender/composables/*, packages/components/src/sender/context/*
Added useSenderCore, useEditor, useModeSwitch, useAutoSize, useSlotScope, useKeyboardShortcuts, composables barrel, and useSenderContext; removed legacy hooks (useInputHandler, useKeyboardHandler, useSuggestionHandler, useUndoRedo).
Layouts & editor content
packages/components/src/sender/index.vue, packages/components/src/sender/components/layouts/*, packages/components/src/sender/components/editor-content/index.vue, packages/components/src/sender/components/footer/index.vue
Replaced monolithic sender UI with SingleLine/MultiLine layouts, new EditorContent wrapper, Footer component, and slot wiring delegating to core.
Sender-actions (UI & types)
packages/components/src/sender-actions/*
Added ActionButton, SubmitButton, ClearButton, UploadButton (file selection + validation), VoiceButton (speech lifecycle + events), WordCounter, DefaultActionButtons, tooltip util, and type barrels; exported components and types.
SenderCompat (legacy shim)
packages/components/src/sender-compat/*
Added compatibility component and legacy type adapter mapping old Sender API to the new core.
Extensions — Mention
packages/components/src/sender/extensions/mention/*
New Mention extension: node, node-view, plugin (floating suggestions), commands, mention-list/view components, utils, types, and styles; exported barrel and helper mention(...).
Extensions — Suggestion
packages/components/src/sender/extensions/suggestion/*
New Suggestion extension: plugin (autocomplete, decorations, popup), types, highlight/filter utils, suggestion-list component updates, and styles; exported barrel and helper suggestion(...).
Extensions — Template
packages/components/src/sender/extensions/template/*
New TemplateBlock and TemplateSelect nodes, node views, commands, zero-width handling, keyboard/paste plugins, select dropdown manager, utilities, types, and barrel exports; provided template(...) helper.
Extensions utilities & constants
packages/components/src/sender/extensions/utils/*, packages/components/src/sender/extensions/constants.ts
Added id generator, position finder, keyboard helpers, constants, and utils barrel.
Removed legacy components / typings
packages/components/src/sender/components/ActionButtons.vue, .../TemplateEditor.vue, .../Block.vue, .../global.d.ts, packages/components/src/sender/types/editor.type.ts
Deleted legacy ActionButtons, TemplateEditor, Block components, ambient global.d.ts, and legacy editor-type declarations.
Removed legacy composables
`packages/components/src/sender/composables/(useInputHandler
useKeyboardHandler
Styles & theming
packages/components/src/styles/*, packages/components/src/sender/index.less, packages/components/src/styles/variables.css
Added extensive CSS variables (light/dark) for sender, suggestion, mention, template; new Less mixin and small variant; registered sender styles in components index.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as Sender UI (Layouts + Actions)
    participant Core as useSenderCore / Context
    participant Editor as TipTap Editor
    participant Ext as Extensions (Mention/Template)
    participant Speech as SpeechHandler
    participant Files as FileDialog

    User->>UI: Type or interact
    UI->>Core: setContent / invoke action
    Core->>Editor: update document
    Editor->>Ext: plugin state updates
    Ext-->>Core: structured data / suggestions
    Core-->>UI: expose state (hasContent, canSubmit)

    User->>UI: Click Submit
    UI->>Core: submit()
    Core->>Editor: getTextWithTemplates / getMentions
    Editor-->>Core: text + structuredData
    Core-->>UI: emit submit(text, structuredData)

    User->>UI: Click Voice Button
    UI->>Speech: start()
    Speech->>UI: interim/final transcripts
    UI->>Editor: insert transcript
    Editor->>Ext: update
Loading
sequenceDiagram
    participant User
    participant UI as UploadButton
    participant Files as FileDialog
    participant Core as useSenderCore

    User->>UI: Click Upload
    UI->>Files: open file dialog
    Files-->>UI: selected files
    UI->>UI: validate maxCount / maxSize
    alt valid
        UI->>Core: emit select(files)
    else invalid
        UI->>Core: emit error(Error, files?)
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I nibble at the editor's root,

Mentions sprout and templates shoot,
Suggestions hop with tiny cheer,
Voice and files and counters near,
Old burrows close — new tunnels root.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'refactor(sender): refactor sender components for 0.4.0 version' clearly summarizes the main change: a major refactoring of the sender components for a new version release.
Docstring Coverage ✅ Passed Docstring coverage is 95.45% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 7, 2026

✅ Preview build completed successfully!

Click the image above to preview.
Preview will be automatically removed when this PR is closed.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 7, 2026

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/components/src/sender-actions/voice-button/useSpeechHandler.ts (1)

74-82: Potential issues in restart logic.

Two concerns with the restart flow:

  1. The setTimeout callback calls handler.start(callbacks) without the try-catch wrapper used at line 84-89, so startup errors during restart won't be caught.

  2. If the component unmounts during the 200ms delay, the setTimeout will still fire, potentially causing errors or unexpected behavior since the handler was already cleaned up in onUnmounted.

🔎 Proposed fix
+  let restartTimer: ReturnType<typeof setTimeout> | null = null
+
   // 开始录音
   const start = () => {
     // ...existing checks...

     // 如果正在录音,先停止再重新开始
     if (speechState.isRecording) {
       handler.stop()
       speechState.isRecording = false
       // 短暂延迟后重新开始
-      setTimeout(() => {
-        handler.start(callbacks)
+      restartTimer = setTimeout(() => {
+        restartTimer = null
+        try {
+          handler.start(callbacks)
+        } catch (error) {
+          speechState.error = error instanceof Error ? error : new Error('启动失败')
+          optionsRef.value.onError?.(speechState.error)
+        }
       }, 200)
       return
     }
     // ...rest of function...
   }

   // 组件卸载时清理资源
   onUnmounted(() => {
+    if (restartTimer) {
+      clearTimeout(restartTimer)
+      restartTimer = null
+    }
     if (speechState.isRecording && handler) {
       handler.stop()
       speechState.isRecording = false
     }
   })
🤖 Fix all issues with AI agents
In @packages/components/src/sender-compat/index.vue:
- Around line 63-80: The computed defaultActions erroneously maps
props.buttonGroup.file to actions.clear; change the mapping so
props.buttonGroup.file populates a proper file/upload action (e.g., actions.file
or actions.upload) with its properties (disabled and tooltip) and keep
actions.clear reserved for clear/submit logic; update the computed
(defaultActions) to assign actions.file = { disabled:
props.buttonGroup.file.disabled, tooltip: props.buttonGroup.file.disabled ?
'无法上传' : '上传文件' } (or similar localized tooltip) instead of assigning to
actions.clear, and ensure any consumers of defaultActions use the new
actions.file/upload key.

In @packages/components/src/sender/extensions/template/select/plugins.ts:
- Around line 99-104: The deletion loop uses original positions so earlier
deletes shift later indices and cause incorrect removals; update the logic that
iterates over todoPositions (where tr.delete(pos, pos+1) is called) to process
positions in reverse order or adjust by a running offset—e.g., iterate from
todoPositions.length-1 down to 0, extract [, pos] for each entry and call tr =
tr.delete(pos, pos + 1), then return tr; this ensures each zero-width character
is removed using correct, non-shifted indices.

In @packages/components/src/sender/extensions/template/types.ts:
- Line 102: The type annotation for the items prop is wrong: `Ref` is declared
with two type parameters in `items?: TemplateItem[] | Ref<TemplateItem[],
TemplateItem[]>`; change it to a single-parameter Ref such as
`Ref<TemplateItem[]>` so the union becomes `items?: TemplateItem[] |
Ref<TemplateItem[]>`; update the import/use sites of `TemplateItem` and `Ref` if
needed to satisfy the corrected type.
- Around line 15-30: The SelectOption interface is duplicated; remove the local
interface declaration in this file and instead import the canonical SelectOption
type from the base types module (the existing interface named SelectOption in
the base types). Replace the local definition with an import (and re-export if
this module should expose it) and update any local usages to reference the
imported SelectOption to avoid type drift.
- Around line 1-10: The compat layer only maps the old 'template' variant to
'block' but ignores the sender TemplateItem variants 'text' and 'select'; update
the sender-compat conversion logic (the mapping function in the sender-compat
component that currently converts 'template' → 'block') to either map 'text' and
'select' to appropriate compat types (e.g., 'text' → 'text' or 'block' as
chosen, 'select' → 'select' or a handled fallback) or explicitly
throw/log/annotate unsupported types, and add a brief comment documenting the
intentional type differences between TemplateItem in sender and sender-compat
and which variants are supported or intentionally dropped.
🟡 Minor comments (16)
packages/components/src/sender/extensions/template/commands.ts-122-156 (1)

122-156: focusFirstTemplate returns before async operation completes.

The command returns true at line 155 before the setTimeout callback executes. If an error occurs inside the callback (line 150-152), the caller already received a success indication. Additionally, blocks is captured before the timeout but the document may have changed by the time the callback runs.

Consider either:

  1. Making this an async command if Tiptap supports it
  2. Documenting that success means "scheduled" not "completed"
  3. Re-fetching blocks inside the callback to ensure fresh data
🔎 Suggested improvement
   focusFirstTemplate:
     () =>
     ({ editor }: { editor: Editor }) => {
-      const blocks = getAllTemplates(editor)
-
       // 使用 setTimeout 确保在文档更新后执行
       setTimeout(() => {
+        const blocks = getAllTemplates(editor)
         const { state, view } = editor
         const tr = state.tr
packages/components/src/sender/extensions/mention/components/mention-list.vue-84-86 (1)

84-86: :key uses optional item.id which may be undefined.

Per MentionItem type, id is optional. Using undefined as a key can cause Vue warnings or unexpected re-renders. Consider a fallback.

🔎 Proposed fix
-      :key="item.id"
+      :key="item.id ?? `mention-${index}`"
packages/components/src/sender/extensions/mention/components/mention-list.vue-31-32 (1)

31-32: Potential index of -1 when items array is empty.

Math.min(props.items.length - 1, ...) evaluates to -1 when items.length is 0, which could cause issues if ArrowDown is pressed on an empty list.

🔎 Proposed fix
   if (event.key === 'ArrowDown') {
-    selectedIndex.value = Math.min(props.items.length - 1, selectedIndex.value + 1)
+    if (props.items.length > 0) {
+      selectedIndex.value = Math.min(props.items.length - 1, selectedIndex.value + 1)
+    }
     scrollToSelected()
     return true
   }
packages/components/src/sender/extensions/mention/components/mention-list.vue-46-56 (1)

46-56: Global document.querySelector is fragile with multiple instances.

If multiple mention-list components exist on the page, document.querySelector('.mention-item.is-selected') will find the first match globally, not necessarily within this component. Use a template ref instead.

🔎 Proposed fix using template ref
+const listRef = ref<HTMLElement | null>(null)
+
 function scrollToSelected() {
   nextTick(() => {
-    const selectedElement = document.querySelector('.mention-item.is-selected')
+    const selectedElement = listRef.value?.querySelector('.mention-item.is-selected')
     if (selectedElement) {
       selectedElement.scrollIntoView({
         block: 'nearest',
         behavior: 'smooth',
       })
     }
   })
 }

And in template:

-  <div class="mention-list">
+  <div ref="listRef" class="mention-list">

Committable suggestion skipped: line range outside the PR's diff.

packages/components/src/sender/composables/useKeyboardShortcuts.ts-16-17 (1)

16-17: Only submitType is destructured from params, but interface expects more.

Per UseKeyboardShortcutsParams in types/composables.ts, the params include canSubmit, mode, submit, and setMode which are not referenced anywhere in the function implementation. The caller in useSenderCore.ts passes all five parameters, but the function only uses submitType. Consider narrowing the interface using Pick<UseKeyboardShortcutsParams, 'submitType'> to clarify intent and reduce unnecessary parameter passing.

packages/components/src/sender/extensions/template/select/extension.ts-44-47 (1)

44-47: Add error handling for JSON.parse in options attribute.

JSON.parse can throw if data-options contains malformed JSON (e.g., from copy-paste or corrupted HTML). This would crash the editor during content loading.

🔎 Proposed fix
       options: {
         default: [],
         parseHTML: (element) => {
           const optionsStr = element.getAttribute('data-options')
-          return optionsStr ? JSON.parse(optionsStr) : []
+          if (!optionsStr) return []
+          try {
+            return JSON.parse(optionsStr)
+          } catch {
+            return []
+          }
         },
packages/components/src/sender/extensions/template/block/extension.ts-26-42 (1)

26-42: Watch cleanup and edge cases in onCreate.

A few concerns with the watch setup:

  1. Redundant isRef check: Line 33 re-checks isRef(items) but line 29 already confirmed items is a ref.

  2. Missing watch cleanup: The watch return value (stop handle) is not captured. In Tiptap, onCreate doesn't automatically clean up Vue watchers when the extension is destroyed, which could cause memory leaks or stale callbacks if the editor is recreated.

  3. UX concern: focusFirstTemplate() is called on every items change (due to immediate: true + watch), which may unexpectedly steal focus when template data is updated programmatically.

🔎 Proposed fix
+  // Store the stop function for cleanup
+  private stopWatch?: () => void
+
   onCreate() {
     const { items } = this.options

     if (items && isRef(items)) {
-      watch(
+      this.stopWatch = watch(
         items,
         () => {
-          const currentItems = isRef(items) ? items.value : items
+          const currentItems = items.value
           if (currentItems !== null && currentItems !== undefined) {
             this.editor.commands.setTemplateData(currentItems)
-            this.editor.commands.focusFirstTemplate()
+            // Consider only focusing on initial load, not on every update
           }
         },
         { deep: true, immediate: true },
       )
     }
   },
+
+  onDestroy() {
+    this.stopWatch?.()
+  },

Committable suggestion skipped: line range outside the PR's diff.

packages/components/src/sender/extensions/template/types.ts-84-88 (1)

84-88: Align example with actual TemplateItem union types.

The JSDoc example on line 87 uses type: 'template', but the actual TemplateItem union (from packages/components/src/sender/index.type.ts lines 52-118) defines types as 'text' | 'block' | 'select', not 'template'.

This mismatch could confuse API consumers and lead to runtime errors.

🔎 Proposed fix
  * @example 响应式配置(推荐)
  * ```typescript
  * const items = ref<TemplateItem[]>([
  *   { type: 'text', content: '帮我分析' },
- *   { type: 'template', content: '' }
+ *   { type: 'block', content: '' }
  * ])
  * Template.configure({ items })  // 传入 ref,自动双向绑定
  * ```
packages/components/src/sender-actions/action-button/index.vue-34-34 (1)

34-34: Add a comment explaining the focus event suppression or verify it doesn't break accessibility features.

The @focus.capture="(event: FocusEvent) => event.stopPropagation()" pattern is unique to this component and undocumented. While it prevents focus events from bubbling, this is only applied to the button inside the tooltip (line 34), not the fallback button without a tooltip. Other similar button components (submit-button, upload-button, voice-button) that also use TinyTooltip do not suppress focus events.

If this suppression is intentional to prevent tooltip-specific focus side effects, document the rationale. Otherwise, consider removing it or verify that it doesn't interfere with keyboard navigation, focus indicators, or screen reader announcements.

packages/components/src/sender/extensions/mention/extension.ts-100-117 (1)

100-117: Watcher created in onCreate is never cleaned up.

The watch created when items is a ref will persist for the lifetime of the extension. If the extension is destroyed and recreated, old watchers may accumulate. Consider using onDestroy to clean up:

🔎 Proposed fix
+import { watch, isRef, type WatchStopHandle } from 'vue'

 export const Mention = Node.create<MentionOptions>({
   // ... existing code ...

+  addStorage() {
+    return {
+      unwatch: null as WatchStopHandle | null,
+    }
+  },

   onCreate() {
     const { items } = this.options

     if (isRef(items)) {
-      watch(
+      this.storage.unwatch = watch(
         items,
         () => {
           const tr = this.editor.state.tr
           tr.setMeta('mention-update', true)
           this.editor.view.dispatch(tr)
         },
         { deep: true },
       )
     }
   },

+  onDestroy() {
+    this.storage.unwatch?.()
+  },
packages/components/src/sender/composables/useModeSwitch.ts-59-74 (1)

59-74: Potential memory leak: setTimeout callback may fire after unmount.

If the component unmounts while isAutoSwitching is true, the 300ms timeout continues running. Consider clearing the timeout on cleanup or using useTimeoutFn from @vueuse/core.

🔎 Proposed fix
+import { useTimeoutFn } from '@vueuse/core'
+
 export function useModeSwitch(
   props: SenderProps,
   editor: Ref<Editor | undefined>,
   editorRef: Ref<HTMLElement | null>,
 ): UseModeSwitchReturn {
+  const { start: startAutoSwitchTimeout, stop: stopAutoSwitchTimeout } = useTimeoutFn(() => {
+    isAutoSwitching.value = false
+  }, 300, { immediate: false })
+
   // ... existing code ...

   const setMode = (mode: InputMode) => {
     if (currentMode.value === mode) return

     isAutoSwitching.value = true
     currentMode.value = mode

     nextTick(() => {
       if (editor.value) {
         editor.value.commands.focus('end')
       }

-      setTimeout(() => {
-        isAutoSwitching.value = false
-      }, 300)
+      startAutoSwitchTimeout()
     })
   }

Committable suggestion skipped: line range outside the PR's diff.

packages/components/src/sender/extensions/suggestion/types.ts-260-269 (1)

260-269: Replace the local EditorState interface with an import from @tiptap/pm/state.

Other extensions in the codebase (mention, template/block, template/select) already import the actual EditorState type from @tiptap/pm/state. The local interface duplicates the real type and creates a type mismatch when getState receives real editor state instances. Change line 263 from export interface EditorState to:

import type { EditorState } from '@tiptap/pm/state'

Then remove the local interface definition (lines 263-269).

packages/components/src/sender/types/composables.ts-12-16 (1)

12-16: Remove unused KeyboardHandlers interface.

The KeyboardHandlers interface (lines 12-16) is never used anywhere in the codebase. Additionally, it incorrectly includes a submit method which is not returned by useKeyboardShortcuts (the submit parameter is passed in, not returned). Either remove this unused interface or clarify its intended purpose if it should be kept.

packages/components/src/index.ts-23-31 (1)

23-31: ActionButton naming conflict shadows attachments interface in root barrel export.

The root index.ts re-exports ActionButton interface from attachments/index.type.ts (line 39), but then explicitly exports the ActionButton component from sender-actions (line 138-139). This causes the explicit component export to shadow the wildcard interface export, making the attachments ActionButton interface inaccessible to consumers importing from the root barrel.

Consider renaming one of these exports or managing them explicitly to avoid shadowing (e.g., export the attachments interface under a different name like AttachmentActionButton).

packages/components/src/sender-compat/index.vue-273-291 (1)

273-291: Add ref="voiceRef" to VoiceButton in #footer-right slot for multi-line mode compatibility.

The startSpeech and stopSpeech methods rely on voiceRef to control the VoiceButton. While the VoiceButton in #actions-inline (single-line mode) has ref="voiceRef", the VoiceButton in #footer-right (multi-line mode) is missing this ref attribute. This causes speech control methods to fail when the component is in multi-line mode.

Add ref="voiceRef" to the VoiceButton at line 282-291 to ensure consistent behavior across layout modes.

packages/components/src/sender/index.vue-7-14 (1)

7-14: Inconsistent default value for autoSize.

The default value here is { minRows: 1, maxRows: 5 }, but the JSDoc in index.type.ts (line 102) documents @default { minRows: 1, maxRows: 3 }. Please align the actual default with the documentation.

🔎 Proposed fix

Either update the code:

-  autoSize: () => ({ minRows: 1, maxRows: 5 }),
+  autoSize: () => ({ minRows: 1, maxRows: 3 }),

Or update the documentation in index.type.ts:

-   * @default { minRows: 1, maxRows: 3 }
+   * @default { minRows: 1, maxRows: 5 }
🧹 Nitpick comments (35)
packages/components/src/sender/extensions/template/block/plugins.ts (2)

416-427: Redundant event.key === 'Delete' check.

The static analysis tool correctly identified that the condition event.key === 'Delete' on line 421 is always true since we're already inside the if (event.key === 'Delete' && selection.empty) block (line 408). This redundant check can be removed for clarity.

🔎 Proposed fix
             if (
               $from.pos === $from.start() &&
               content.length === 1 &&
-              content !== ZERO_WIDTH_CHAR &&
-              event.key === 'Delete'
+              content !== ZERO_WIDTH_CHAR
             ) {

226-236: Similar redundant check for event.key === 'Backspace'.

Same pattern as the Delete handler - line 230 redundantly checks event.key === 'Backspace' when we're already inside the Backspace handling block (line 217).

🔎 Proposed fix
             if (
               $from.pos === $from.end() &&
               content.length === 1 &&
-              content !== ZERO_WIDTH_CHAR &&
-              event.key === 'Backspace'
+              content !== ZERO_WIDTH_CHAR
             ) {
packages/components/src/sender/extensions/template/commands.ts (1)

10-11: Consider a more explicit type definition for PMNode.

The current type ReturnType<Editor['state']['doc']['nodeAt']> & { nodeSize: number } is clever but fragile. nodeAt returns Node | null, and intersecting with { nodeSize: number } doesn't actually narrow away the null case.

🔎 Proposed fix
-// ProseMirror Node 类型
-type PMNode = ReturnType<Editor['state']['doc']['nodeAt']> & { nodeSize: number }
+import type { Node as PMNode } from '@tiptap/pm/model'

Then update the getAllTemplates return type to Array<{ node: PMNode; pos: number }>.

packages/components/src/sender-compat/index.type.ts (1)

17-41: Type duplication for compatibility layer.

These types (SpeechCallbacks, SpeechHandler, SpeechConfig) are duplicated from packages/components/src/sender-actions/voice-button/speech.types.ts. While this isolation is intentional for the compatibility layer, consider re-exporting from the canonical source to reduce maintenance burden:

export type { SpeechCallbacks, SpeechHandler, SpeechConfig } from '../sender-actions/voice-button/speech.types'

If the compat layer needs to remain fully isolated (e.g., for potential version divergence), the current approach is acceptable.

packages/components/src/sender-actions/types/tooltip.ts (1)

1-1: Use type-only import for better tree-shaking.

Since this file only uses VNode as a type, use a type-only import to help bundlers optimize unused imports.

🔎 Proposed fix
-import type { VNode } from 'vue'
+import type { VNode } from 'vue'

Wait, the import already uses import type. Let me re-check. Actually looking at line 1, it shows import type { VNode } from 'vue' - this is already correct. Let me revise.

packages/components/src/sender-actions/default-actions/index.vue (1)

11-20: Consider the nested transition pattern for clarity and consistency.

The template uses nested Transition components with the same name (tr-slide-right):

  • Line 11: Outer transition wrapping the button group
  • Line 13: Inner transition wrapping only the ClearButton

This nested pattern with identical transition names could lead to conflicting animations when hasContent changes. Additionally, the SubmitButton (line 17) lacks a transition wrapper, creating inconsistency.

Consider one of the following approaches:

  1. Use different transition names for nested transitions if staggered animation is intended
  2. Remove the inner transition and apply the same transition behavior to all buttons uniformly
  3. Document the intended animation behavior if this pattern is deliberate
packages/components/src/sender/extensions/utils/id-generator.ts (1)

17-19: Consider crypto.randomUUID() for better uniqueness guarantees.

The current implementation using Math.random() is suitable for UI element IDs but has two potential weaknesses:

  1. Collision risk when multiple IDs are generated in the same millisecond
  2. The random suffix may be shorter than 7 characters for very small random values

If the runtime supports it, consider using crypto.randomUUID() or ensure the random suffix is padded to a consistent length.

🔎 Proposed enhancement with crypto.randomUUID()
 export function generateId(prefix: string): string {
-  return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
+  // Use crypto.randomUUID if available, fallback to current approach
+  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+    return `${prefix}_${crypto.randomUUID()}`
+  }
+  const random = Math.random().toString(36).slice(2, 9).padEnd(7, '0')
+  return `${prefix}_${Date.now()}_${random}`
 }
packages/components/src/sender/components/footer/index.vue (1)

16-16: Consider explicit comparison for maxLength.

The condition showWordLimit && maxLength relies on JavaScript's truthiness, which treats 0 as falsy. While this is likely intentional (no limit = no counter), an explicit comparison maxLength > 0 would make the intent clearer.

🔎 Suggested clarification
-      <WordCounter v-if="showWordLimit && maxLength" />
+      <WordCounter v-if="showWordLimit && maxLength > 0" />
packages/components/src/sender/components/editor-content/index.vue (2)

32-47: Document browser-specific scrollbar styling.

The ::-webkit-scrollbar pseudo-elements only work in WebKit-based browsers (Chrome, Edge, Safari). Firefox users will see the default scrollbar. Consider adding a fallback using the standard scrollbar-width and scrollbar-color properties for broader compatibility.

🔎 Optional Firefox fallback
 .tr-sender-editor-scroll {
   flex: 1;
   min-width: 0;
   overflow-y: hidden; // 默认隐藏,由 JS 控制
+  
+  // Firefox fallback
+  scrollbar-width: thin;
+  scrollbar-color: rgba(0, 0, 0, 0.15) transparent;

   // 滚动条样式
   &::-webkit-scrollbar {

40-46: Extract scrollbar colors to CSS variables for theme support.

The hardcoded rgba(0, 0, 0, ...) values won't adapt to dark themes. Consider extracting these to CSS variables (e.g., --tr-sender-scrollbar-thumb, --tr-sender-scrollbar-thumb-hover) defined in the theme system.

🔎 Proposed refactor

First, add variables to sender.less:

scrollbar-thumb: rgba(0, 0, 0, 0.15);
scrollbar-thumb-hover: rgba(0, 0, 0, 0.25);

Then update this component:

   &::-webkit-scrollbar-thumb {
-    background: rgba(0, 0, 0, 0.15);
+    background: var(--tr-sender-scrollbar-thumb);
     border-radius: 3px;

     &:hover {
-      background: rgba(0, 0, 0, 0.25);
+      background: var(--tr-sender-scrollbar-thumb-hover);
     }
   }
packages/components/src/sender/components/layouts/SingleLineLayout.vue (1)

37-40: DefaultActionButtons will render alongside custom actions, but visibility is internally controlled.

While DefaultActionButtons is rendered unconditionally, it has internal conditional logic (v-if="hasContent || loading") that controls whether its buttons (ClearButton and SubmitButton) are actually visible. This means custom slot content via actions-inline and default action buttons can coexist in the same flex container, showing both when appropriate.

If the intent is to show only one or the other (custom actions OR defaults, not both), consider using a slot fallback:

Suggested pattern for mutually exclusive actions
-  <slot name="actions-inline" v-bind="slotScope" />
-  <DefaultActionButtons />
+  <slot name="actions-inline" v-bind="slotScope">
+    <DefaultActionButtons />
+  </slot>

Otherwise, if both are intended to coexist, document this behavior to avoid future confusion.

packages/components/src/sender/composables/useEditor.ts (1)

46-48: Consider wiring maxLength to CharacterCount extension.

The CharacterCount extension is configured with only mode: 'textSize', but props.maxLength is available and could be used to enforce character limits at the editor level.

🔎 Proposed enhancement
      CharacterCount.configure({
        mode: 'textSize',
+       limit: props.maxLength,
      }),
packages/components/src/sender-actions/clear-button/index.vue (2)

32-32: Redundant isDisabled in show computed and :disabled prop.

When isDisabled is true, show evaluates to false and the button won't render. This means when the button does render, isDisabled is always false, making the :disabled="isDisabled" binding always false. If the intent is to hide disabled buttons entirely, remove the :disabled prop; if the intent is to show but disable the button, remove !isDisabled from show.

🔎 Option A: Remove redundant disabled prop (current behavior preserved)
   <ActionButton
     v-if="show"
     :icon="IconClear"
-    :disabled="isDisabled"
     :tooltip="tooltipRenderFn"
     :tooltip-placement="tooltipPlacement"
     @click="handleClick"
   />
🔎 Option B: Show disabled button instead of hiding
-const show = computed(() => clearable.value && hasContent.value && !loading.value && !isDisabled.value)
+const show = computed(() => clearable.value && hasContent.value && !loading.value)

Also applies to: 44-52


37-41: The isDisabled guard in handleClick is unreachable.

Since show is false when isDisabled is true, the button won't be rendered, so handleClick can never be called when isDisabled is true. This guard is dead code.

🔎 Simplified handler
 const handleClick = () => {
-  if (!isDisabled.value) {
-    clear()
-  }
+  clear()
 }
packages/components/src/sender/components/layouts/MultiLineLayout.vue (1)

7-8: Consider destructuring only what's needed from context.

context is only used for context.editor in the template. Destructuring improves clarity:

🔎 Proposed simplification
-const context = useSenderContext()
+const { editor } = useSenderContext()
 const slotScope = useSlotScope()

And in template:

-<slot name="content" :editor="context.editor">
+<slot name="content" :editor="editor">
packages/components/src/sender/extensions/mention/commands.ts (1)

32-44: Position corruption risk when deleting nodes during traversal.

The tr.delete() call modifies the document structure while descendants() is iterating. Although return false stops iteration after the first match, if this pattern is ever extended to delete multiple nodes, the positions would become invalid after the first deletion.

Consider using dispatch pattern or collecting positions first, then deleting in reverse order:

🔎 Safer deletion pattern (for future-proofing)
  deleteMention:
    (id: string) =>
    ({ tr, state }: { tr: Editor['state']['tr']; state: Editor['state'] }) => {
      let deleted = false
+     let nodePos: number | null = null
+     let nodeSize: number = 0
      state.doc.descendants((node, pos) => {
        if (node.type.name === 'mention' && node.attrs.id === id) {
-         tr.delete(pos, pos + node.nodeSize)
-         deleted = true
+         nodePos = pos
+         nodeSize = node.nodeSize
          return false
        }
      })
+     if (nodePos !== null) {
+       tr.delete(nodePos, nodePos + nodeSize)
+       deleted = true
+     }
      return deleted
    },
packages/components/src/sender/extensions/template/select/template-select-view.vue (2)

226-228: Global keydown listener may interfere with other components.

The handleKeyDown listener is attached to document and will capture keyboard events even when other dropdowns or components are focused. Consider scoping this to only handle events when this specific dropdown is open and focused.

🔎 Suggested improvement
 onMounted(() => {
-  document.addEventListener('keydown', handleKeyDown)
+  // Only add listener when dropdown is open (in openDropdown)
 })

Move the listener registration into openDropdown() and removal into closeDropdown():

const openDropdown = async () => {
  // ... existing code ...
  document.addEventListener('keydown', handleKeyDown)
}

const closeDropdown = async () => {
  document.removeEventListener('keydown', handleKeyDown)
  // ... existing code ...
}

329-390: Consider scoping global styles more specifically.

The unscoped <style lang="less"> block defines .template-select__dropdown and .template-select__option globally. While necessary for Teleport content, these class names could potentially conflict with other components.

Consider adding a more unique prefix or using CSS custom properties for theming consistency.

packages/components/src/sender/composables/useAutoSize.ts (2)

28-41: autoSize: true is silently ignored.

Based on the type AutoSize = boolean | { minRows: number; maxRows: number }, when autoSize is true, the computed returns null, effectively disabling auto-sizing. This may be unexpected behavior for users who pass autoSize={true} expecting default row limits.

Consider providing default values when autoSize === true:

🔎 Proposed fix
  const autoSizeConfig = computed(() => {
    if (autoSize === false || autoSize === undefined) {
      return null
    }

+   // autoSize: true 使用默认值
+   if (autoSize === true) {
+     return {
+       minRows: 1,
+       maxRows: 6,
+     }
+   }
+
    if (typeof autoSize === 'object') {
      return {
        minRows: autoSize.minRows,
        maxRows: autoSize.maxRows,
      }
    }

    return null
  })

74-92: Duplicate updateHeight calls on initialization.

Both watchers have { immediate: true }, causing updateHeight() to be called twice on mount. Consider consolidating into a single watcher or removing immediate from one:

🔎 Consolidate watchers
- watch(
-   currentMode,
-   () => {
-     nextTick(() => {
-       updateHeight()
-     })
-   },
-   { immediate: true },
- )
-
- watch(
-   autoSizeConfig,
-   () => {
-     nextTick(() => {
-       updateHeight()
-     })
-   },
-   { immediate: true },
- )
+ watch(
+   [currentMode, autoSizeConfig],
+   () => {
+     nextTick(() => {
+       updateHeight()
+     })
+   },
+   { immediate: true },
+ )
packages/components/src/styles/variables.css (1)

127-188: Well-structured theme variables for new Sender components.

The new CSS variables for Sender, Suggestion, Mention, and Template components follow the established --tr-* naming convention and are well-organized by feature area.

Consider moving theme-invariant variables (e.g., --tr-suggestion-item-font-size, --tr-suggestion-item-icon-size, --tr-sender-template-border-radius, --tr-sender-template-padding, --tr-sender-template-margin, --tr-sender-template-min-width) to the :root block since they have identical values in both light and dark themes. This would reduce duplication and make maintenance easier.

packages/components/src/sender-actions/action-button/index.vue (1)

23-48: Reduce duplication between tooltip and non-tooltip branches.

The button element is duplicated across lines 31-40 and 44-48, differing only in the tooltip wrapper. This makes maintenance harder if button props or slots need to change.

🔎 Suggested refactor to extract button into a reusable template ref
 <script setup lang="ts">
 import { computed } from 'vue'
 import { TinyTooltip } from '@opentiny/vue'
 import type { ActionButtonProps } from '../types/common'
 import { normalizeTooltipContent } from '../utils/tooltip'
 
 const props = withDefaults(defineProps<ActionButtonProps>(), {
   disabled: false,
   active: false,
   size: 32,
   tooltipPlacement: 'top',
 })
 
 const tooltipRenderFn = computed(() => normalizeTooltipContent(props.tooltip))
 
 const sizeStyle = computed(() => {
   const size = typeof props.size === 'number' ? `${props.size}px` : props.size
   return { fontSize: size }
 })
 </script>
 
 <template>
+  <component
+    :is="props.tooltip ? TinyTooltip : 'div'"
+    v-bind="props.tooltip ? {
+      renderContent: tooltipRenderFn,
+      placement: props.tooltipPlacement,
+      effect: 'light',
+      visibleArrow: false,
+      popperClass: 'tr-action-button-tooltip-popper'
+    } : {}"
+  >
-  <tiny-tooltip
-    v-if="props.tooltip"
-    :render-content="tooltipRenderFn"
-    :placement="props.tooltipPlacement"
-    effect="light"
-    :visible-arrow="false"
-    popper-class="tr-action-button-tooltip-popper"
-  >
     <button
       :class="['tr-action-button', { active: props.active }]"
       :disabled="props.disabled"
       @focus.capture="(event: FocusEvent) => event.stopPropagation()"
     >
-      <!-- 优先使用插槽,如果没有插槽则使用 icon prop -->
       <slot name="icon">
         <component :is="props.icon" :style="sizeStyle" />
       </slot>
     </button>
-  </tiny-tooltip>
-
-  <!-- 无 tooltip 时直接渲染按钮 -->
-  <button v-else :class="['tr-action-button', { active: props.active }]" :disabled="props.disabled">
-    <slot name="icon">
-      <component :is="props.icon" :style="sizeStyle" />
-    </slot>
-  </button>
+  </component>
 </template>
packages/components/src/sender/extensions/suggestion/extension.ts (1)

28-42: Consider performance implications of deep watching suggestion items.

The watch with { deep: true } on line 39 will recursively track all nested properties of the items array. For large suggestion lists or frequently updated items, this could cause performance issues.

If only the array reference or top-level properties need to be tracked, consider using a shallow watch or restructuring to avoid deep reactivity.

💡 Alternative approach if deep watching isn't necessary
 onCreate() {
   if (isRef(this.options.items)) {
     watch(
       this.options.items,
       () => {
         // 触发更新
         const tr = this.editor.state.tr
         // 使用一个特殊的 meta 来触发插件更新,虽然实际上只要有 dispatch 就会触发 apply
         tr.setMeta(SuggestionPluginKey, { type: 'update' })
         this.editor.view.dispatch(tr)
       },
-      { deep: true },
+      // Remove deep: true if only array reference changes need to be tracked
     )
   }
 },

Consider this change only if the suggestion items don't have nested properties that need reactivity, or if the array reference is replaced on updates.

packages/components/src/sender-actions/submit-button/index.vue (1)

37-80: Consider extracting the button element to reduce duplication.

Similar to the action-button component, the submit button element is duplicated across the tooltip and non-tooltip branches (lines 45-57 vs 61-80). The duplication is more complex here due to loading state rendering.

While extraction might be less straightforward given the loading state differences, consider whether a computed component or render function could reduce the duplication without sacrificing readability.

packages/components/src/sender/extensions/template/utils.ts (1)

13-13: Consider using a literal regex instead of new RegExp().

Since ZERO_WIDTH_CHAR is a compile-time constant (not user input), the static analysis ReDoS warning is a false positive. However, you can simplify and clarify the intent by using a literal regex:

🔎 Proposed refactor
-export const ZERO_WIDTH_CHAR = '\u200B'
+export const ZERO_WIDTH_CHAR = '\u200B'
+export const ZERO_WIDTH_CHAR_REGEX = /\u200B/g

Then use ZERO_WIDTH_CHAR_REGEX in the replace calls:

-const content = (node.textContent || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '')
+const content = (node.textContent || '').replace(ZERO_WIDTH_CHAR_REGEX, '')
-const text = (node.text || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '')
+const text = (node.text || '').replace(ZERO_WIDTH_CHAR_REGEX, '')

Also applies to: 38-38, 53-53

packages/components/src/sender/extensions/mention/plugin.ts (1)

339-346: Magic number +2 for cursor position is fragile.

The cursor calculation assumes mention node size (1) + space (1) = 2. Consider computing from actual node sizes for maintainability:

🔎 Proposed refactor
   // 插入 mention 节点和空格
   tr.insert(range.from, [mentionNode, spaceNode])

-  // 设置光标到空格之后(mention 节点 + 空格 = +2)
-  const cursorPos = range.from + 2
+  // 设置光标到空格之后
+  const cursorPos = range.from + mentionNode.nodeSize + spaceNode.nodeSize
   tr.setSelection(TextSelection.create(tr.doc, cursorPos))
packages/components/src/sender/extensions/suggestion/types.ts (1)

130-130: Consider moving import to the top of the file.

The import type { Ref } from 'vue' statement is placed mid-file. While TypeScript hoists imports, placing them at the top improves readability and follows standard conventions.

packages/components/src/sender-actions/upload-button/index.vue (1)

38-54: Hardcoded Chinese error messages limit i18n support.

Error messages like 最多只能选择 ${props.maxCount} 个文件 and 以下文件超过 ${props.maxSize}MB 限制 should be externalized for internationalization. Consider using an i18n solution or allowing custom error messages via props.

packages/components/src/sender/extensions/template/select/dropdown-manager.ts (1)

59-67: Potential race condition between cleanup and delayed listener attachment.

If the returned cleanup function is called before the setTimeout callback executes, removeEventListener will be called on a listener that was never added. While this is harmless, consider using a flag to track whether the listener was actually attached.

🔎 Proposed fix
 export function setupClickOutside(
   selectElement: HTMLElement,
   dropdownElement: HTMLElement,
   onClose: () => void,
 ): () => void {
+  let listenerAdded = false
+
   const handleClickOutside = (event: MouseEvent) => {
     const target = event.target as Node

     if (!selectElement.contains(target) && !dropdownElement.contains(target)) {
       onClose()
       document.removeEventListener('click', handleClickOutside)
+      listenerAdded = false
     }
   }

   // 延迟添加监听器,避免立即触发
   setTimeout(() => {
     document.addEventListener('click', handleClickOutside)
+    listenerAdded = true
   }, 0)

   // 返回清理函数
   return () => {
-    document.removeEventListener('click', handleClickOutside)
+    if (listenerAdded) {
+      document.removeEventListener('click', handleClickOutside)
+    }
   }
 }
packages/components/src/sender/composables/useSenderCore.ts (2)

69-78: Consider caching text extraction to avoid redundant computation.

getTextWithTemplates(editor.value) is called twice—once in hasContent (line 71) and again in characterCount (line 77). Since both computed properties depend on the same editor state, the extraction happens twice per reactivity cycle.

🔎 Proposed optimization
+  // Cache the extracted text to avoid redundant computation
+  const textContent = computed(() => {
+    if (!editor.value) return ''
+    return getTextWithTemplates(editor.value)
+  })
+
   const hasContent = computed(() => {
-    if (!editor.value) return false
-    const text = getTextWithTemplates(editor.value)
-    return text.trim().length > 0
+    return textContent.value.trim().length > 0
   })

   const characterCount = computed(() => {
-    if (!editor.value) return 0
-    const text = getTextWithTemplates(editor.value)
-    return text.length
+    return textContent.value.length
   })

215-223: Consider using nextTick instead of setTimeout(0) for Vue-idiomatic deferral.

While setTimeout(0) works, Vue's nextTick is more semantically appropriate for waiting until DOM updates complete and ensures consistency with Vue's update cycle.

🔎 Proposed change
+import { computed, provide, toRef, watch, nextTick } from 'vue'
...
   watch(
     () => editor.value?.state.doc.content,
     () => {
-      setTimeout(() => {
+      nextTick(() => {
         checkOverflow()
-      }, 0)
+      })
     },
     { deep: true },
   )
packages/components/src/sender/extensions/suggestion/plugin.ts (1)

259-262: The justClosed guard with setTimeout(0) may be timing-sensitive.

The zero-delay timeout relies on the event loop ordering, which can vary across browsers. If this proves flaky, consider a small delay (e.g., 10-50ms) or using a frame-based approach with requestAnimationFrame.

packages/components/src/sender/index.vue (1)

128-135: Consider using CSS variables for scrollbar colors.

The scrollbar thumb colors are hardcoded with rgba(0, 0, 0, ...) which may not work well with dark themes. Other components in this PR (e.g., suggestion-list.vue) use CSS variables like --tr-suggestion-scrollbar-thumb-color for consistency.

🔎 Proposed fix
     &::-webkit-scrollbar-thumb {
-      background: rgba(0, 0, 0, 0.2);
+      background: var(--tr-sender-scrollbar-thumb-color, rgba(0, 0, 0, 0.2));
       border-radius: 4px;

       &:hover {
-        background: rgba(0, 0, 0, 0.3);
+        background: var(--tr-sender-scrollbar-thumb-hover-color, rgba(0, 0, 0, 0.3));
       }
     }
packages/components/src/sender/index.type.ts (2)

160-161: Consider a more specific extension type.

The any[] fallback reduces type safety. If the intent is to support configured extensions (e.g., from .configure()), consider using Extension<any, any>[] or creating a custom union type that captures known extension shapes.

🔎 Proposed fix
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  extensions?: Extension[] | any[]
+  extensions?: Extension<any, any>[]

Or if AnyExtension is exported from @tiptap/core:

import type { Extension, AnyExtension } from '@tiptap/core'
// ...
extensions?: AnyExtension[]

331-337: Consider using a more specific type for editor.

Using unknown for the editor instance loses type safety and IDE autocompletion. Consider importing and using the Tiptap Editor type:

import type { Editor } from '@tiptap/core'

content?: (props: { editor: Editor | null }) => unknown

This provides better developer experience when accessing editor methods in the slot.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @packages/components/src/sender-compat/index.vue:
- Around line 228-252: The UploadButton and VoiceButton are being rendered twice
because they’re unconditionally present in both the #actions-inline and
#footer-right slots; change their v-if checks to render them only in the
appropriate layout mode (e.g., inline for single-line mode, footer for
multi-line mode) by gating on a layout/computed flag (e.g., isInlineActions or
singleLineMode) instead of just allowFiles/allowSpeech, and ensure the
footer-right VoiceButton also receives ref="voiceRef" and the same props/events
as the inline VoiceButton so startSpeech()/stopSpeech() continue to work; update
UploadButton/VoiceButton usages to use allowFiles/allowSpeech && isInlineActions
for the inline slot and allowFiles/allowSpeech && !isInlineActions for the
footer slot.
🧹 Nitpick comments (4)
packages/components/src/sender-compat/index.vue (1)

212-212: Redundant event handler with v-model.

The @update:model-value handler is unnecessary since v-model="modelValue" already handles two-way binding through the computed setter on line 18-20.

🔎 Proposed fix
     @focus="handleFocus"
     @blur="handleBlur"
-    @update:model-value="(val) => (modelValue = val)"
   >
packages/components/src/sender-compat/index.type.ts (3)

33-37: Consider renaming to PascalCase for consistency.

The interface fileUploadConfig uses camelCase, but TypeScript convention prefers PascalCase for type names (e.g., FileUploadConfig). While this doesn't affect functionality, aligning with the convention improves consistency with VoiceButtonConfig below.

🔎 Proposed refactor
-interface fileUploadConfig {
+interface FileUploadConfig {
   accept?: string
   multiple?: boolean
   reset?: boolean
 }

And update the reference:

 export interface ButtonGroupConfig {
-  file?: ControlState & fileUploadConfig
+  file?: ControlState & FileUploadConfig
   submit?: ControlState
   voice?: VoiceButtonConfig
 }

54-67: Consider importing types instead of duplicating definitions.

SuggestionTextPart and HighlightFunction are duplicated from packages/components/src/sender/extensions/suggestion/types.ts. While the comments indicate these are for compatibility, duplicating types can lead to drift if the source definitions change. Consider importing and re-exporting instead:

🔎 Proposed refactor
+import type { SuggestionTextPart, HighlightFunction } from '../sender/extensions/suggestion/types'
+
 // ============================================
 // 建议项相关类型(旧版兼容层专用)
 // 注意:这些类型与新版 sender 的 SuggestionItem 不同,仅用于兼容旧版 API
 // ============================================

-// 高亮片段类型
-export interface SuggestionTextPart {
-  text: string
-  isMatch: boolean
-}
-
-// 高亮函数类型
-type HighlightFunction = (suggestionText: string, inputText: string) => SuggestionTextPart[]
+// Re-export for compatibility
+export type { SuggestionTextPart, HighlightFunction }

 // 建议项类型
 export interface ISuggestionItem {
   content: string
   highlights?: string[] | HighlightFunction
 }

131-135: Consider simplifying UserTemplateItem for clarity.

The Omit<Pick<CompatTemplateItem, 'type' | 'content'>, 'id'> is redundant since Pick doesn't include 'id' in the first place. The type works correctly but could be simplified for readability.

🔎 Proposed simplification
 export type UserTextItem = Omit<CompatTextItem, 'id'> & { id?: CompatTextItem['id'] }
 
-export type UserTemplateItem = Omit<Pick<CompatTemplateItem, 'type' | 'content'>, 'id'> & {
+export type UserTemplateItem = Pick<CompatTemplateItem, 'type' | 'content'> & {
   id?: CompatTemplateItem['id']
 }
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5435e01 and e93141b.

📒 Files selected for processing (3)
  • packages/components/src/sender-compat/index.type.ts
  • packages/components/src/sender-compat/index.vue
  • packages/components/src/sender/extensions/template/types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/components/src/sender/extensions/template/types.ts
🧰 Additional context used
🧬 Code graph analysis (1)
packages/components/src/sender-compat/index.type.ts (6)
packages/components/src/sender/extensions/index.ts (2)
  • SuggestionTextPart (17-17)
  • HighlightFunction (18-18)
packages/components/src/sender/extensions/suggestion/types.ts (2)
  • SuggestionTextPart (10-13)
  • HighlightFunction (22-22)
packages/components/src/sender/index.ts (2)
  • SuggestionTextPart (60-60)
  • HighlightFunction (61-61)
packages/components/src/sender/types/base.ts (3)
  • AutoSize (264-264)
  • InputMode (13-13)
  • SubmitTrigger (21-21)
packages/components/src/sender-actions/index.type.ts (1)
  • SpeechConfig (12-12)
packages/components/src/sender-actions/voice-button/speech.types.ts (1)
  • SpeechConfig (25-32)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (7)
packages/components/src/sender-compat/index.vue (4)

63-73: Previous issue addressed: incorrect buttonGroup.file mapping removed.

The incorrect mapping of buttonGroup.file to actions.clear has been removed. The current implementation correctly maps only buttonGroup.submit, while file upload functionality is handled via direct UploadButton rendering in slots.


83-105: Well-implemented template data transformation with proper guards.

The setTemplateData method correctly:

  • Guards against unready editor state with a helpful warning
  • Handles empty data by clearing content
  • Transforms legacy template type to new block type

107-186: Event handlers and exposed methods are well-structured.

The implementation correctly:

  • Forwards all events with proper typing
  • Uses optional chaining for defensive method calls
  • Exposes a clean public API via defineExpose

29-61: Dynamic extension access pattern is acceptable for compatibility layer.

The use of any casts to access Sender.Suggestion and Sender.Template is a pragmatic approach for this compatibility wrapper. The null checks (lines 38, 56) before use provide adequate safety.

packages/components/src/sender-compat/index.type.ts (3)

6-8: LGTM!

The imports are correct and align with the existing type definitions in the codebase.


10-31: LGTM!

The basic type definitions and ControlState interface are well-structured and provide appropriate flexibility for tooltip configuration.


70-112: LGTM!

The SenderProps and SenderEmits definitions are comprehensive and correctly structured for Vue 3. Event signatures follow the proper emit pattern.

@SonyLeo SonyLeo linked an issue Jan 7, 2026 that may be closed by this pull request
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
packages/components/src/sender-compat/index.vue (1)

190-212: Redundant @update:model-value handler.

Line 212 is redundant since v-model="modelValue" (line 192) already expands to include @update:model-value="val => modelValue = val". The explicit handler duplicates this behavior.

♻️ Suggested fix
     :default-actions="defaultActions"
     :stop-text="stopText"
     @submit="handleSubmit"
     @clear="handleClear"
     @cancel="handleCancel"
     @focus="handleFocus"
     @blur="handleBlur"
-    @update:model-value="(val) => (modelValue = val)"
   >
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e93141b and 2b9b78f.

📒 Files selected for processing (1)
  • packages/components/src/sender-compat/index.vue
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-06-25T07:04:18.791Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 123
File: packages/components/src/bubble/message/Message.vue:22-40
Timestamp: 2025-06-25T07:04:18.791Z
Learning: In the Message component (packages/components/src/bubble/message/Message.vue), the renderer resolution is intentionally not reactive to type changes - the component is designed to work with a fixed type that doesn't change after initialization.

Applied to files:

  • packages/components/src/sender-compat/index.vue
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (12)
packages/components/src/sender-compat/index.vue (12)

1-13: LGTM!

Clean imports and prop definitions with sensible defaults.


15-23: LGTM!

Correct pattern for v-model forwarding with proper nullish coalescing fallback.


25-27: LGTM!

Properly typed component refs using InstanceType<typeof Component>.


29-61: LGTM!

The dynamic access pattern for Sender extensions is appropriate for a compatibility layer. The unconditional addition of Template extension ensures setTemplateData works regardless of initial configuration.


63-73: Previous issue addressed.

The incorrect mapping of buttonGroup.file to actions.clear has been removed. Now only submit action is mapped correctly.


75-81: LGTM!

Clean normalization of speech config - boolean true uses defaults while object provides full configuration.


83-105: LGTM!

Well-structured with proper null checking and helpful console warning. The type conversion from 'template' to 'block' properly handles API migration.


107-186: LGTM!

Clean event forwarding with thin handlers and properly guarded method delegation using optional chaining.


214-226: LGTM!

Proper conditional slot forwarding with scoped slot props passed through correctly.


227-252: Previous issue addressed.

The duplicate button rendering concern has been resolved by adding mode-based conditional rendering (mode === 'single'). VoiceButton properly receives ref="voiceRef" for speech control methods.


254-259: LGTM!

Footer slot composition maps legacy footer-left alongside footer content into the new Sender's footer slot.


261-287: Previous issues addressed.

Both past review concerns are resolved:

  1. Mode-based conditional rendering (mode === 'multiple') prevents duplicate buttons
  2. VoiceButton at line 278 now includes ref="voiceRef" ensuring startSpeech()/stopSpeech() work in multi-line mode

@SonyLeo SonyLeo changed the base branch from release/v0.3.x to develop January 8, 2026 03:01
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

🤖 Fix all issues with AI agents
In @.github/workflows/auto-build-main-module-docs.yml:
- Around line 63-72: In the "Update PR status" step fix the shell conditional by
quoting and using POSIX equality: replace the line if [ ${{ steps.build.outcome
}} == 'success' ]; with a quoted comparison such as if [ "${{
steps.build.outcome }}" = "success" ]; or set an env var (e.g.,
BUILD_OUTCOME="${{ steps.build.outcome }}") and then use if [ "$BUILD_OUTCOME" =
"success" ]; ensuring proper quotes and single = for portability.
- Around line 30-46: The script uses an untrusted branch name (github.head_ref)
assigned to BRANCH_NAME and interpolates it into shell commands (git fetch
origin $BRANCH_NAME, git checkout $BRANCH_NAME), creating a command-injection
risk; fix by either switching to actions/checkout@v3 with ref: ${{
github.head_ref }} or, if you need shell use, sanitize and safely pass the
variable: assign BRANCH_NAME="${{ github.head_ref }}", validate it against a
strict regex (e.g., only [A-Za-z0-9._/-]) and exit on mismatch, then use safe
git invocations with explicit separator and quoting (git fetch origin --
"$BRANCH_NAME" && git checkout -- "$BRANCH_NAME") to prevent injection.

In @packages/components/src/sender-actions/upload-button/index.vue:
- Around line 50-53: The error message lists all oversized files but the
component only emits the first one; change the emit call to pass the full
oversized array (emit('error', error, oversized)) so consumers receive all
offending files, and update the event type in index.type.ts (the error event
signature) to accept an array of File objects instead of a single File; ensure
props.maxSize and the oversized variable are left unchanged.

In @packages/components/src/sender/composables/useAutoSize.ts:
- Around line 28-41: autoSizeConfig currently ignores the case when autoSize ===
true and thus disables auto-sizing; update the computed to return a default
config when autoSize is strictly true (e.g., provide sensible defaults for
minRows and maxRows) so that AutoSize = true enables resizing; specifically
modify the autoSizeConfig computed (which reads the autoSize prop/variable) to
check for autoSize === true and return an object with default minRows and
maxRows before falling back to null, keeping existing handling for object values
(autoSize.minRows / autoSize.maxRows).

In @packages/components/src/sender/composables/useModeSwitch.ts:
- Around line 59-74: The setMode function schedules a setTimeout to reset
isAutoSwitching after 300ms but never clears that timer on component unmount,
risking a memory leak; capture the timeout id when calling setTimeout (e.g.,
store in a local variable like autoSwitchTimeoutId), clear any previous timeout
before assigning a new one, and register an onBeforeUnmount hook to clear
autoSwitchTimeoutId so that isAutoSwitching.value = false cannot run after the
component is destroyed; references: setMode, isAutoSwitching, currentMode,
nextTick, editor, onBeforeUnmount.

In @packages/components/src/sender/composables/useSenderCore.ts:
- Around line 186-191: The setTimeout(0) used after calling setMode('multiple')
to defer editorInstance.commands.splitBlock() and focus is a fragile race
workaround; replace it by awaiting Vue's nextTick (and if needed a second
nextTick) after setMode('multiple') to ensure DOM and reactive updates complete
before calling editorInstance.commands.splitBlock() and
editorInstance.commands.focus(); update the flow in the function containing
setMode and editorInstance to await nextTick(s) instead of using setTimeout so
the mode switch is reliably applied before splitBlock/focus.
- Around line 166-172: The plugin-state checks assume the plugin keys are
present and can throw or return undefined; update the handler around the
MentionPluginKey.getState(view.state), SuggestionPluginKey.getState(view.state),
and TemplateSelectDropdownPluginKey.getState(view.state) calls to first confirm
each plugin key exists (or wrap in try/catch) and only access .active or .isOpen
if the returned state is non-null/defined; keep the existing early return
behavior (return false) when any plugin state indicates active/isOpen, but avoid
direct property access on undefined by guarding each call (e.g., check the
result of getState before reading .active/.isOpen or handle exceptions).
- Around line 109-123: The code is using repeated string literals for extension
names (e.g., 'template', 'mention') which is brittle; define and export shared
constants like TEMPLATE_EXTENSION = 'template' and MENTION_EXTENSION = 'mention'
in a central module (e.g., extensions/constants) and replace all literal checks
in useSenderCore (the conditional that calls
getTemplateStructuredData/getTextWithTemplates and
getMentionStructuredData/getTextWithMentions), mention/utils.ts, and
suggestion/plugin.ts to use those constants so the checks are consistent and
compile-time refactoring-safe.
- Around line 156-205: The injected handleKeyDown must be cleaned up: update the
watch callback that calls editorInstance.setOptions to return a cleanup function
which resets the handler (e.g. editorInstance.setOptions({ editorProps: {
...editorInstance.options.editorProps, handleKeyDown: undefined }})) when the
editor instance changes; additionally register an onBeforeUnmount/onUnmounted
inside this composable to clear the handler (or call editor.destroy()) so the
dynamic keyboard logic (handleKeyDown, keyboardHandlers, submit, setMode,
editorInstance.commands) is not leaked after unmount.

In
@packages/components/src/sender/extensions/mention/components/mention-list.vue:
- Around line 46-56: The scrollToSelected function currently uses
document.querySelector('.mention-item.is-selected') which can pick items outside
this component; change it to scope the query to this component's root/list
element via a template ref (e.g., a ref like listRef or rootRef) and query that
ref (ref.value.querySelector('.mention-item.is-selected')) inside the existing
nextTick callback so only items in this MentionList component are targeted;
update the template to add the corresponding ref to the container element and
use that ref name in the scrollToSelected implementation.

In @packages/components/src/sender/extensions/mention/extension.ts:
- Around line 100-117: The watcher created in onCreate (checking isRef(items)
and calling watch) must be cleaned up on extension destroy and must set metadata
with the same key/shape the plugin expects: instead of
tr.setMeta('mention-update', true) use the MentionPluginKey with an object
matching plugin expectations (e.g., tr.setMeta(MentionPluginKey, { type:
'update' })) and save the watch stop handle (const stopWatcher = watch(...)) so
you can implement onDestroy() to call stopWatcher(); update references to items,
isRef, watch, onCreate, onDestroy, tr.setMeta, and MentionPluginKey accordingly.
- Around line 140-142: The mentionCommands object lacks an explicit type
annotation, weakening TipTap type safety; update the export of mentionCommands
in commands.ts to declare a precise type that matches the Commands augmentation
in types.ts (e.g., annotate it so each entry is a curried command taking the
correct arg and context types for Editor), ensuring insertMention and
deleteMention signatures use Partial<MentionAttrs>, Editor['commands'],
Editor['state'], and tr types accordingly (or create a dedicated Commands-like
type and apply it to mentionCommands) so the object is fully type-checked
against the Commands interface.

In @packages/components/src/sender/extensions/template/block/plugins.ts:
- Around line 613-619: Deleting $from.nodeBefore first shifts document positions
so using the original $from.pos for the subsequent $from.nodeAfter deletion is
unsafe; update the position with the transaction mapping or compute fixed
offsets before mutating. Modify the block that checks ZERO_WIDTH_CHAR so you
either (a) compute start/end positions for both deletions up front (e.g.,
beforePos = $from.pos, beforeSize = $from.nodeBefore?.nodeSize, afterSize =
$from.nodeAfter?.nodeSize) and call tr.delete using those computed numeric
ranges, or (b) after performing the first tr.delete, remap the original
$from.pos via tr.mapping.map($from.pos) and use that remapped position for the
second tr.delete; apply this change around the ZERO_WIDTH_CHAR checks to ensure
correct deletion targets for $from.nodeBefore and $from.nodeAfter.
- Around line 417-427: The duplicated check for event.key === 'Delete' is
redundant because this block already sits inside the outer if that ensures
event.key === 'Delete' && selection.empty; remove the inner event.key ===
'Delete' condition and similarly remove duplicate event.key checks in the other
backspace/delete branches (the blocks that reference ZERO_WIDTH_CHAR, $from.pos,
dispatch(state.tr.insertText(...)), and selection.empty) so each inner
conditional only tests the remaining conditions (e.g., $from.pos ===
$from.start(), content length, content !== ZERO_WIDTH_CHAR) before inserting the
ZERO_WIDTH_CHAR and calling event.preventDefault() and return true.

In @packages/components/src/sender/extensions/template/commands.ts:
- Around line 119-156: The code captures blocks via getAllTemplates(editor)
before the setTimeout, causing a stale closure; move the call to
getAllTemplates(editor) (and any use of editor.state/doc) inside the setTimeout
callback in focusFirstTemplate so template nodes/positions are recomputed after
document updates, then compute targetPos and create the TextSelection from the
fresh state before dispatching the transaction and focusing the view.

In @packages/components/src/sender/extensions/template/select/extension.ts:
- Around line 44-47: The parseHTML function currently calls JSON.parse on
element.getAttribute('data-options') which can throw on malformed JSON; wrap the
parse in a try/catch inside parseHTML (referencing parseHTML,
element.getAttribute('data-options'), and optionsStr) and on error return a safe
default (e.g., []), optionally logging or silently swallowing the parse error;
ensure the function still returns the same type in success and failure cases.

In @packages/components/src/sender/extensions/template/types.ts:
- Around line 63-80: The examples in the JSDoc use an invalid TemplateItem type
value 'template'; update both example blocks to use the correct union member
'block' instead. Locate the examples that construct items (the
ref<TemplateItem[]>() example and the static Template.configure({ items: [...]
}) example) and replace type: 'template' with type: 'block' so they match the
TemplateItem type definition.

In @packages/components/src/sender/extensions/template/utils.ts:
- Around line 43-51: The current templateSelect branch only stores the selected
option's value as content and drops other metadata; update the templateSelect
handling (the block checking node.type.name === 'templateSelect') to populate
the TemplateItem['select'] object with optional fields from node.attrs: include
options (node.attrs.options), value (node.attrs.value), and placeholder (if
present), while still preserving content (selectedOption?.value || ''), so the
pushed item contains type: 'select' and the full select payload { content,
options, value, placeholder } with absent fields omitted or undefined.

In @packages/components/src/sender/index.type.ts:
- Around line 93-104: The JSDoc for the autoSize prop in index.type.ts says the
default is { minRows: 1, maxRows: 3 } but the implementation in index.vue sets {
minRows: 1, maxRows: 5 }; pick one and make them consistent: either update the
autoSize default in index.vue to { minRows: 1, maxRows: 3 } (so the component
default matches the documented @default) or update the JSDoc in index.type.ts to
document { minRows: 1, maxRows: 5 } instead; ensure you reference the autoSize
prop and adjust both files so the documented @default and the runtime default
are identical.
🧹 Nitpick comments (20)
packages/components/src/sender/extensions/utils/id-generator.ts (1)

1-6: Duplicate documentation block.

The file-level comment (lines 1-5) and the function-level JSDoc (lines 7-16) both describe "生成唯一 ID". Consider removing the file-level block since the function already has comprehensive documentation.

packages/components/src/sender/components/footer/index.vue (1)

37-42: Minor CSS redundancy.

The margin-left: auto on &-right is redundant since the parent already uses justify-content: space-between. You can remove it without affecting the layout.

♻️ Suggested simplification
   &-right {
     display: flex;
     align-items: center;
     gap: var(--tr-sender-gap);
-    margin-left: auto;
   }
packages/components/src/sender-actions/voice-button/index.type.ts (1)

37-41: Potential API overlap with speechConfig.onVoiceButtonClick.

Both onButtonClick (here) and speechConfig.onVoiceButtonClick (in speech.types.ts) provide button click interception. Consider documenting the precedence or consolidating to avoid consumer confusion.

packages/components/src/sender/extensions/suggestion/extension.ts (1)

28-42: Watcher lacks cleanup on extension destroy.

The watch call in onCreate doesn't return a stop handle to clean up when the extension/editor is destroyed. If the editor is destroyed while the ref still exists, the watcher continues running.

♻️ Suggested fix
+import { watch, isRef, type WatchStopHandle } from 'vue'
+
 export const Suggestion = Extension.create<SuggestionOptions>({
   name: 'suggestion',
+  
+  addStorage() {
+    return {
+      stopWatch: null as WatchStopHandle | null,
+    }
+  },

   onCreate() {
     if (isRef(this.options.items)) {
-      watch(
+      this.storage.stopWatch = watch(
         this.options.items,
         () => {
           const tr = this.editor.state.tr
           tr.setMeta(SuggestionPluginKey, { type: 'update' })
           this.editor.view.dispatch(tr)
         },
         { deep: true },
       )
     }
   },
+
+  onDestroy() {
+    this.storage.stopWatch?.()
+  },
 })
packages/components/src/sender/extensions/template/utils.ts (1)

37-38: Static analysis false positive; consider pre-compiling regex for efficiency.

The ReDoS warnings are false positives since ZERO_WIDTH_CHAR is a constant single character. However, creating the regex on each descendant callback is slightly wasteful.

♻️ Pre-compile the regex
 export const ZERO_WIDTH_CHAR = '\u200B'
+const ZERO_WIDTH_REGEX = new RegExp(ZERO_WIDTH_CHAR, 'g')

 // Then use ZERO_WIDTH_REGEX.replaceAll or .replace() calls:
-const content = (node.textContent || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '')
+const content = (node.textContent || '').replace(ZERO_WIDTH_REGEX, '')

Also applies to: 52-53

packages/components/src/sender/extensions/template/select/template-select-view.vue (1)

226-228: Global keydown listener added per component instance.

Each template-select-view instance adds its own document.addEventListener('keydown', ...). With multiple selects, all handlers run on every keypress. The early return on line 153 prevents action, but consider using event delegation or checking showDropdown more efficiently.

For a minor optimization, consider only registering the listener when the dropdown opens and removing it on close, rather than keeping it mounted for the component's lifetime.

packages/components/src/sender/composables/useModeSwitch.ts (2)

70-72: Extract the magic number 300ms to a named constant.

The 300ms timeout appears to coordinate with animation/transition timing but is embedded as a magic number. Extract it to a named constant (e.g., MODE_SWITCH_DURATION_MS) for maintainability and to clarify its purpose.

♻️ Proposed refactor
+const MODE_SWITCH_DURATION_MS = 300
+
 export function useModeSwitch(
   props: SenderProps,
   editor: Ref<Editor | undefined>,
   editorRef: Ref<HTMLElement | null>,
 ): UseModeSwitchReturn {
   // ...
   const setMode = (mode: InputMode) => {
     if (currentMode.value === mode) return

     isAutoSwitching.value = true
     currentMode.value = mode

     nextTick(() => {
       if (editor.value) {
         editor.value.commands.focus('end')
       }

       setTimeout(() => {
         isAutoSwitching.value = false
-      }, 300)
+      }, MODE_SWITCH_DURATION_MS)
     })
   }

34-35: Guard against missing ProseMirror element more defensively.

The query .querySelector('.ProseMirror') assumes a specific DOM structure. While there's an early return if editorElement is null, consider logging a warning in development to help debug structural issues.

packages/components/src/sender/composables/useSenderCore.ts (1)

215-223: Another setTimeout(0) workaround - consider refactoring timing dependencies.

Similar to the keyboard handler, using setTimeout(0) to trigger overflow checking after content updates is a workaround. The { deep: true } watch on editor.value?.state.doc.content combined with a timeout suggests the overflow check needs to wait for layout.

Consider using watchPostEffect or consolidating these timing-dependent operations.

packages/components/src/sender-actions/upload-button/index.vue (1)

46-54: Extract file size validation logic to a utility function.

The file size validation logic (converting MB to bytes, filtering oversized files) could be extracted to a utility function for reusability and testability.

packages/components/src/sender/extensions/suggestion/types.ts (2)

130-130: Move import statement to the top of the file.

The Ref import from Vue should be placed at the top of the file with other imports for consistency with TypeScript conventions.

♻️ Suggested fix
+import type { Ref } from 'vue'
+
 /**
  * Suggestion 插件类型定义
  *
  * 包含建议项、高亮、插件配置和状态等类型定义
  */
 
 // ... (at line 130, remove)
-import type { Ref } from 'vue'

260-269: Consider importing EditorState from @tiptap/pm/state instead of redefining it.

This minimal EditorState interface duplicates the official ProseMirror type. Using the actual type from @tiptap/pm/state would provide better type safety and ensure compatibility with future ProseMirror updates.

♻️ Suggested approach
+import type { EditorState } from '@tiptap/pm/state'
+
 /**
  * 插件 Key 类型
  *
  * 用于访问插件状态
  */
 export interface SuggestionPluginKeyType {
   getState: (state: EditorState) => SuggestionState | undefined
 }
-
-/**
- * EditorState 类型(来自 @tiptap/pm/state)
- */
-export interface EditorState {
-  doc: unknown
-  selection: unknown
-  storedMarks: unknown
-  schema: unknown
-  [key: string]: unknown
-}
packages/components/src/sender/extensions/template/commands.ts (1)

10-11: Type assertion may be fragile—nodeAt returns Node | null.

The PMNode type alias assumes nodeAt always returns a node, but it can return null. The getAllTemplates function pushes nodes from descendants which provides non-null nodes, so this works, but the type could be cleaner.

♻️ Suggested improvement
-// ProseMirror Node 类型
-type PMNode = ReturnType<Editor['state']['doc']['nodeAt']> & { nodeSize: number }
+import type { Node as PMNode } from '@tiptap/pm/model'
packages/components/src/sender/extensions/mention/plugin.ts (3)

341-346: Hardcoded cursor offset assumes fixed node sizes—use actual node sizes instead.

The cursor position calculation range.from + 2 assumes the mention node size is 1 and the space is 1. If the mention node's structure changes or attributes affect its size, this will break. Use the actual node sizes for robustness.

♻️ Proposed fix
   // 插入 mention 节点和空格
   tr.insert(range.from, [mentionNode, spaceNode])

-  // 设置光标到空格之后(mention 节点 + 空格 = +2)
-  const cursorPos = range.from + 2
+  // 设置光标到空格之后
+  const cursorPos = range.from + mentionNode.nodeSize + spaceNode.nodeSize
   tr.setSelection(TextSelection.create(tr.doc, cursorPos))

243-266: Popup appended to document.body may cause z-index or scrolling issues in certain layouts.

Appending the popup directly to document.body (line 266) can cause issues with stacking contexts, especially in modals or iframes. Consider using a configurable mount point or Teleport-like pattern.


61-67: Resource cleanup variables should be scoped or managed more explicitly.

The component, popup, and cleanup variables are declared in the outer function scope and mutated across plugin lifecycle methods. While this works, it can be error-prone. Consider encapsulating these in a state object for clarity.

packages/components/src/sender/extensions/suggestion/plugin.ts (2)

54-66: Dynamic extension lookup may fail silently if extension is not registered.

getCurrentSuggestions looks up the extension by name and accesses its options. If the extension isn't found, it returns an empty array silently. Consider adding a warning in development mode to help debug configuration issues.

♻️ Suggested improvement
   function getCurrentSuggestions(): SuggestionItem[] {
     const suggestionExtension = editor.extensionManager.extensions.find((ext) => ext.name === 'suggestion')
+    
+    if (!suggestionExtension && process.env.NODE_ENV === 'development') {
+      console.warn('[Suggestion] Extension not found in editor')
+    }
+    
     const options = suggestionExtension?.options
     const items = options?.items || options?.suggestions || []

259-262: justClosed flag with setTimeout(0) is a timing-sensitive workaround.

This pattern prevents immediate reopening but relies on JavaScript event loop timing. Consider using a more explicit state machine or debounce mechanism for robustness across different browsers/environments.

packages/components/src/sender-compat/index.vue (1)

30-61: Consider adding type declarations to avoid any casts for extension access.

The (Sender as any).Suggestion and (Sender as any).Template casts are necessary but reduce type safety. Consider augmenting the Sender type to include these static properties, which would eliminate the need for eslint-disable comments.

♻️ Suggested approach in a `.d.ts` file
// In sender/index.type.ts or a declaration file
declare module './index.vue' {
  interface SenderComponent {
    Suggestion: typeof import('./extensions').Suggestion
    Template: typeof import('./extensions').Template
    Mention: typeof import('./extensions').Mention
  }
}
packages/components/src/sender/index.vue (1)

128-135: Consider using CSS variables for scrollbar colors.

The scrollbar styling uses hardcoded rgba(0, 0, 0, 0.2) and rgba(0, 0, 0, 0.3) values while the variables.css file defines theme-aware scrollbar variables like --tr-suggestion-scrollbar-thumb-color. For consistent theming in dark mode, consider extracting these to CSS variables.

♻️ Suggested refactor
     &::-webkit-scrollbar-thumb {
-      background: rgba(0, 0, 0, 0.2);
+      background: var(--tr-sender-scrollbar-thumb-color, rgba(0, 0, 0, 0.2));
       border-radius: 4px;

       &:hover {
-        background: rgba(0, 0, 0, 0.3);
+        background: var(--tr-sender-scrollbar-thumb-hover-color, rgba(0, 0, 0, 0.3));
       }
     }

Then add corresponding variables to variables.css for both light and dark themes.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2b9b78f and c307275.

📒 Files selected for processing (94)
  • .github/workflows/auto-build-main-module-docs.yml
  • .github/workflows/auto-deploy-docs.yml
  • docs/src/guide/plugin-badge.md
  • packages/components/package.json
  • packages/components/src/index.ts
  • packages/components/src/sender-actions/action-button/index.vue
  • packages/components/src/sender-actions/clear-button/index.vue
  • packages/components/src/sender-actions/default-actions/index.vue
  • packages/components/src/sender-actions/index.ts
  • packages/components/src/sender-actions/index.type.ts
  • packages/components/src/sender-actions/submit-button/index.vue
  • packages/components/src/sender-actions/types/common.ts
  • packages/components/src/sender-actions/types/index.ts
  • packages/components/src/sender-actions/types/tooltip.ts
  • packages/components/src/sender-actions/upload-button/index.type.ts
  • packages/components/src/sender-actions/upload-button/index.vue
  • packages/components/src/sender-actions/utils/tooltip.ts
  • packages/components/src/sender-actions/voice-button/index.type.ts
  • packages/components/src/sender-actions/voice-button/index.vue
  • packages/components/src/sender-actions/voice-button/speech.types.ts
  • packages/components/src/sender-actions/voice-button/useSpeechHandler.ts
  • packages/components/src/sender-actions/voice-button/webSpeechHandler.ts
  • packages/components/src/sender-actions/word-counter/index.vue
  • packages/components/src/sender-compat/index.ts
  • packages/components/src/sender-compat/index.type.ts
  • packages/components/src/sender-compat/index.vue
  • packages/components/src/sender/components/ActionButtons.vue
  • packages/components/src/sender/components/Block.vue
  • packages/components/src/sender/components/TemplateEditor.vue
  • packages/components/src/sender/components/editor-content/index.vue
  • packages/components/src/sender/components/footer/index.vue
  • packages/components/src/sender/components/global.d.ts
  • packages/components/src/sender/components/layouts/MultiLineLayout.vue
  • packages/components/src/sender/components/layouts/SingleLineLayout.vue
  • packages/components/src/sender/composables/index.ts
  • packages/components/src/sender/composables/useAutoSize.ts
  • packages/components/src/sender/composables/useEditor.ts
  • packages/components/src/sender/composables/useInputHandler.ts
  • packages/components/src/sender/composables/useKeyboardHandler.ts
  • packages/components/src/sender/composables/useKeyboardShortcuts.ts
  • packages/components/src/sender/composables/useModeSwitch.ts
  • packages/components/src/sender/composables/useSenderCore.ts
  • packages/components/src/sender/composables/useSlotScope.ts
  • packages/components/src/sender/composables/useSuggestionHandler.ts
  • packages/components/src/sender/composables/useUndoRedo.ts
  • packages/components/src/sender/context/index.ts
  • packages/components/src/sender/context/types.ts
  • packages/components/src/sender/extensions/index.ts
  • packages/components/src/sender/extensions/mention/commands.ts
  • packages/components/src/sender/extensions/mention/components/mention-list.vue
  • packages/components/src/sender/extensions/mention/components/mention-view.vue
  • packages/components/src/sender/extensions/mention/extension.ts
  • packages/components/src/sender/extensions/mention/index.less
  • packages/components/src/sender/extensions/mention/index.ts
  • packages/components/src/sender/extensions/mention/plugin.ts
  • packages/components/src/sender/extensions/mention/types.ts
  • packages/components/src/sender/extensions/mention/utils.ts
  • packages/components/src/sender/extensions/suggestion/extension.ts
  • packages/components/src/sender/extensions/suggestion/index.less
  • packages/components/src/sender/extensions/suggestion/index.ts
  • packages/components/src/sender/extensions/suggestion/plugin.ts
  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
  • packages/components/src/sender/extensions/suggestion/types.ts
  • packages/components/src/sender/extensions/suggestion/utils/filter.ts
  • packages/components/src/sender/extensions/suggestion/utils/highlight.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/extensions/template/block/plugins.ts
  • packages/components/src/sender/extensions/template/block/template-block-view.vue
  • packages/components/src/sender/extensions/template/commands.ts
  • packages/components/src/sender/extensions/template/extension.ts
  • packages/components/src/sender/extensions/template/index.ts
  • packages/components/src/sender/extensions/template/select/dropdown-manager.ts
  • packages/components/src/sender/extensions/template/select/extension.ts
  • packages/components/src/sender/extensions/template/select/plugins.ts
  • packages/components/src/sender/extensions/template/select/template-select-view.vue
  • packages/components/src/sender/extensions/template/types.ts
  • packages/components/src/sender/extensions/template/utils.ts
  • packages/components/src/sender/extensions/utils/id-generator.ts
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/extensions/utils/position.ts
  • packages/components/src/sender/index.less
  • packages/components/src/sender/index.ts
  • packages/components/src/sender/index.type.ts
  • packages/components/src/sender/index.vue
  • packages/components/src/sender/types/base.ts
  • packages/components/src/sender/types/components.ts
  • packages/components/src/sender/types/composables.ts
  • packages/components/src/sender/types/context.ts
  • packages/components/src/sender/types/editor.type.ts
  • packages/components/src/sender/types/slots.ts
  • packages/components/src/styles/components/index.css
  • packages/components/src/styles/components/sender.less
  • packages/components/src/styles/variables.css
  • packages/components/vite.config.ts
💤 Files with no reviewable changes (9)
  • packages/components/src/sender/composables/useKeyboardHandler.ts
  • packages/components/src/sender/composables/useSuggestionHandler.ts
  • packages/components/src/sender/composables/useInputHandler.ts
  • packages/components/src/sender/components/Block.vue
  • packages/components/src/sender/components/ActionButtons.vue
  • packages/components/src/sender/components/global.d.ts
  • packages/components/src/sender/components/TemplateEditor.vue
  • packages/components/src/sender/types/editor.type.ts
  • packages/components/src/sender/composables/useUndoRedo.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/src/guide/plugin-badge.md
🚧 Files skipped from review as they are similar to previous changes (41)
  • packages/components/src/sender/extensions/template/extension.ts
  • packages/components/src/sender-actions/types/tooltip.ts
  • packages/components/src/sender/context/types.ts
  • packages/components/src/sender/components/editor-content/index.vue
  • packages/components/src/sender-compat/index.ts
  • packages/components/src/sender-actions/types/common.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/composables/useEditor.ts
  • packages/components/src/sender/extensions/mention/index.ts
  • packages/components/src/styles/components/sender.less
  • packages/components/src/sender/extensions/suggestion/utils/filter.ts
  • packages/components/src/sender/context/index.ts
  • packages/components/src/sender/types/slots.ts
  • packages/components/src/sender/composables/useSlotScope.ts
  • packages/components/src/sender-actions/index.type.ts
  • packages/components/src/sender/extensions/mention/commands.ts
  • packages/components/src/sender-actions/voice-button/speech.types.ts
  • packages/components/src/sender-actions/upload-button/index.type.ts
  • packages/components/src/sender/extensions/mention/types.ts
  • packages/components/src/sender-actions/clear-button/index.vue
  • packages/components/src/sender/extensions/template/select/dropdown-manager.ts
  • packages/components/src/sender/extensions/template/select/plugins.ts
  • packages/components/src/sender/extensions/mention/components/mention-view.vue
  • packages/components/src/sender-actions/types/index.ts
  • packages/components/src/sender-actions/action-button/index.vue
  • packages/components/package.json
  • packages/components/src/sender/extensions/mention/utils.ts
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/extensions/suggestion/index.less
  • packages/components/src/sender-actions/voice-button/index.vue
  • packages/components/src/sender-compat/index.type.ts
  • packages/components/src/sender/types/base.ts
  • packages/components/src/sender/components/layouts/SingleLineLayout.vue
  • packages/components/src/sender/extensions/index.ts
  • packages/components/src/sender-actions/word-counter/index.vue
  • packages/components/src/sender-actions/voice-button/webSpeechHandler.ts
  • packages/components/src/sender/extensions/mention/index.less
  • packages/components/src/sender-actions/default-actions/index.vue
  • packages/components/src/sender-actions/submit-button/index.vue
  • packages/components/src/sender/types/components.ts
  • packages/components/src/sender-actions/utils/tooltip.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-06-18T09:29:47.974Z
Learnt from: SonyLeo
Repo: opentiny/tiny-robot PR: 119
File: packages/components/src/attachments/index.less:213-213
Timestamp: 2025-06-18T09:29:47.974Z
Learning: 在 packages/components/src/attachments/index.less 中,.tr-file-card__close 的背景色使用了硬编码的 rgb(194, 194, 194),但这个UI元素(关闭按钮)将会被直接替换为图标,所以不需要抽取为CSS变量。

Applied to files:

  • packages/components/src/sender/index.less
  • packages/components/src/sender/index.vue
  • packages/components/src/styles/variables.css
📚 Learning: 2025-06-25T07:04:18.791Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 123
File: packages/components/src/bubble/message/Message.vue:22-40
Timestamp: 2025-06-25T07:04:18.791Z
Learning: In the Message component (packages/components/src/bubble/message/Message.vue), the renderer resolution is intentionally not reactive to type changes - the component is designed to work with a fixed type that doesn't change after initialization.

Applied to files:

  • packages/components/src/sender-compat/index.vue
  • packages/components/src/sender/index.vue
📚 Learning: 2025-05-27T03:35:11.008Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 59
File: packages/components/src/suggestion-popover/index.vue:131-133
Timestamp: 2025-05-27T03:35:11.008Z
Learning: In the SuggestionPopover component (packages/components/src/suggestion-popover/index.vue), the click handler can be bound unconditionally because the `show` computed property has a custom setter that prevents state mutations when `props.trigger === 'manual'`. This design centralizes trigger mode logic in the computed property rather than requiring conditional checks in event handlers.

Applied to files:

  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
🧬 Code graph analysis (17)
packages/components/src/sender/composables/useAutoSize.ts (2)
packages/components/src/sender/composables/index.ts (1)
  • useAutoSize (6-6)
packages/components/src/sender/types/base.ts (2)
  • InputMode (13-13)
  • AutoSize (264-264)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
packages/components/src/sender/extensions/utils/index.ts (1)
  • generateId (5-5)
packages/components/src/sender-actions/voice-button/useSpeechHandler.ts (2)
packages/components/src/sender-actions/index.type.ts (2)
  • SpeechState (14-14)
  • SpeechCallbacks (15-15)
packages/components/src/sender-actions/voice-button/speech.types.ts (2)
  • SpeechState (35-39)
  • SpeechCallbacks (5-11)
packages/components/src/sender/extensions/mention/extension.ts (3)
packages/components/src/sender/extensions/mention/types.ts (1)
  • MentionOptions (84-104)
packages/components/src/sender/extensions/mention/plugin.ts (1)
  • createSuggestionPlugin (61-318)
packages/components/src/sender/extensions/mention/commands.ts (1)
  • mentionCommands (12-45)
packages/components/src/sender/extensions/suggestion/utils/highlight.ts (2)
packages/components/src/sender/extensions/suggestion/types.ts (2)
  • SuggestionItem (48-76)
  • SuggestionTextPart (10-13)
packages/components/src/sender-compat/index.type.ts (1)
  • SuggestionTextPart (55-58)
packages/components/src/sender/extensions/mention/plugin.ts (7)
packages/components/src/sender/extensions/index.ts (3)
  • MentionPluginKey (6-6)
  • MentionItem (8-8)
  • suggestion (11-11)
packages/components/src/sender/extensions/mention/index.ts (1)
  • MentionPluginKey (13-13)
packages/components/src/sender/extensions/mention/types.ts (2)
  • MentionSuggestionState (109-129)
  • MentionItem (15-39)
packages/components/src/sender/index.ts (1)
  • MentionItem (47-47)
packages/components/src/sender/extensions/utils/index.ts (2)
  • findTextRange (6-6)
  • generateId (5-5)
packages/components/src/sender/extensions/utils/position.ts (1)
  • findTextRange (26-65)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
  • generateId (17-19)
packages/components/src/sender/composables/useSenderCore.ts (17)
packages/components/src/sender/context/types.ts (1)
  • SenderContext (8-8)
packages/components/src/sender/index.ts (1)
  • SenderContext (41-41)
packages/components/src/sender/types/context.ts (1)
  • SenderContext (11-158)
docs/.vitepress/theme/color-mode.ts (1)
  • emit (25-29)
packages/components/src/sender/composables/index.ts (3)
  • useEditor (5-5)
  • useModeSwitch (7-7)
  • useAutoSize (6-6)
packages/components/src/sender/composables/useEditor.ts (1)
  • useEditor (26-131)
packages/components/src/sender/extensions/index.ts (4)
  • getTextWithTemplates (23-23)
  • getTemplateStructuredData (23-23)
  • MentionPluginKey (6-6)
  • SuggestionPluginKey (11-11)
packages/components/src/sender/extensions/template/index.ts (2)
  • getTextWithTemplates (16-16)
  • getTemplateStructuredData (16-16)
packages/components/src/sender/extensions/template/utils.ts (2)
  • getTextWithTemplates (20-23)
  • getTemplateStructuredData (30-71)
packages/components/src/sender/types/base.ts (1)
  • StructuredData (192-192)
packages/components/src/sender/composables/useModeSwitch.ts (1)
  • useModeSwitch (11-99)
packages/components/src/sender/composables/useKeyboardShortcuts.ts (1)
  • useKeyboardShortcuts (16-78)
packages/components/src/sender/extensions/mention/plugin.ts (2)
  • view (238-316)
  • MentionPluginKey (24-24)
packages/components/src/sender/extensions/suggestion/plugin.ts (2)
  • view (424-504)
  • SuggestionPluginKey (21-21)
packages/components/src/sender/extensions/mention/index.ts (1)
  • MentionPluginKey (13-13)
packages/components/src/sender/extensions/suggestion/index.ts (1)
  • SuggestionPluginKey (13-13)
packages/components/src/sender/composables/useAutoSize.ts (1)
  • useAutoSize (16-97)
packages/components/src/sender-actions/voice-button/index.type.ts (3)
packages/components/src/sender-actions/types/common.ts (2)
  • TooltipContent (5-5)
  • TooltipPlacement (12-24)
packages/components/src/sender-actions/types/tooltip.ts (1)
  • TooltipContent (8-8)
packages/components/src/sender-actions/voice-button/speech.types.ts (1)
  • SpeechConfig (25-32)
packages/components/src/sender/extensions/suggestion/index.ts (2)
packages/components/src/sender/extensions/suggestion/types.ts (2)
  • SuggestionItem (48-76)
  • SuggestionOptions (135-249)
packages/components/src/sender/extensions/suggestion/extension.ts (1)
  • Suggestion (16-52)
packages/components/src/index.ts (2)
packages/components/src/suggestion-pills/index.ts (1)
  • SuggestionPillButton (25-25)
packages/components/src/attachments/index.type.ts (1)
  • ActionButton (45-49)
packages/components/src/sender/composables/useKeyboardShortcuts.ts (1)
packages/components/src/sender/types/composables.ts (2)
  • UseKeyboardShortcutsParams (21-27)
  • UseKeyboardShortcutsReturn (32-35)
packages/components/src/sender/extensions/template/commands.ts (3)
packages/components/src/sender/extensions/template/types.ts (3)
  • TemplateItem (10-10)
  • TemplateAttrs (40-50)
  • TemplateSelectAttrs (15-35)
packages/components/src/sender/types/base.ts (1)
  • TemplateItem (53-119)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
  • generateId (17-19)
packages/components/src/sender/composables/useModeSwitch.ts (4)
packages/components/src/sender-compat/index.type.ts (1)
  • SenderProps (70-93)
packages/components/src/sender/index.type.ts (1)
  • SenderProps (32-227)
packages/components/src/sender/types/composables.ts (1)
  • UseModeSwitchReturn (56-80)
packages/components/src/sender/types/base.ts (1)
  • InputMode (13-13)
packages/components/src/sender/extensions/utils/position.ts (1)
packages/components/src/sender/extensions/utils/index.ts (1)
  • findTextRange (6-6)
packages/components/src/sender/extensions/template/index.ts (3)
packages/components/src/sender/extensions/template/types.ts (2)
  • TemplateItem (10-10)
  • TemplateOptions (55-88)
packages/components/src/sender/types/base.ts (1)
  • TemplateItem (53-119)
packages/components/src/sender/extensions/template/extension.ts (1)
  • Template (14-25)
packages/components/src/sender/extensions/template/block/plugins.ts (1)
packages/components/src/sender/extensions/template/utils.ts (1)
  • ZERO_WIDTH_CHAR (13-13)
packages/components/src/sender/extensions/template/utils.ts (1)
packages/components/src/sender/types/base.ts (1)
  • TemplateItem (53-119)
🪛 actionlint (1.7.10)
.github/workflows/auto-build-main-module-docs.yml

31-31: "github.head_ref" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details

(expression)

🪛 ast-grep (0.40.4)
packages/components/src/sender/extensions/template/utils.ts

[warning] 37-37: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 52-52: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🪛 Biome (2.1.2)
packages/components/src/sender/extensions/template/block/plugins.ts

[error] 423-424: This branch can never execute. Its condition is a duplicate or covered by previous conditions in the if-else-if chain.

(lint/suspicious/noDuplicateElseIf)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🤖 Fix all issues with AI agents
In @packages/components/src/sender-actions/submit-button/index.vue:
- Around line 8-16: The computed isDisabled value is only visual but click
handlers still invoke actions; update the handleClick function (and any other
click handlers in this component that call submit/cancel) to early-return when
isDisabled.value is true: e.g. at start of handleClick check if
(isDisabled.value) return; then keep existing logic that checks loading.value to
call cancel() or submit(); ensure you reference the existing symbols isDisabled,
handleClick, submit, cancel, and loading so the guard is applied wherever clicks
currently trigger those actions; optionally add pointer-events: none to the
.is-disabled style for extra safety.

In @packages/components/src/sender-actions/upload-button/index.vue:
- Around line 50-52: The error message lists all oversized files but the emit
only passes oversized[0]; update the emit call in the upload-button component to
provide the full oversized array (e.g., emit('error', error, oversized) or
attach the array to the Error object) so consumers receive all oversized files;
keep the constructed Error (using props.maxSize and the mapped names) and
replace oversized[0] with the full oversized array (or error.files) when calling
emit('error').

In @packages/components/src/sender-compat/index.vue:
- Around line 190-213: The SenderCompat template duplicates Vue's v-model
handling by using v-model="modelValue" on the <Sender> component and also a
manual @update:model-value="(val) => (modelValue = val)" listener; remove the
explicit @update:model-value listener (the arrow handler) so only the v-model
expansion handles updates, leaving the rest of the props/handlers
(ref="senderRef", @submit="handleSubmit", @clear="handleClear", etc.) intact,
and verify no other code expects the manual listener to exist.

In @packages/components/src/sender/components/layouts/SingleLineLayout.vue:
- Around line 7-9: The v-if guard in the template is checking the Ref objects
themselves (context.showWordLimit and context.maxLength) instead of their
values, so change the condition to unwrap the refs (e.g.,
context.showWordLimit.value && context.maxLength.value) or use unref/context
helpers to evaluate their primitives; locate the useSenderContext() call and the
template v-if that references context.showWordLimit/context.maxLength (and keep
consistency with other checks like context.hasContent.value and
context.loading.value) and update the guard to use .value (or unref) so the
outer conditional reflects the actual runtime booleans/numbers.

In @packages/components/src/sender/composables/useEditor.ts:
- Around line 26-61: The editor initialization uses logical OR which treats
empty string as falsy; update the useTiptapEditor call inside useEditor to use
nullish coalescing for content: content: props.modelValue ?? props.defaultValue
?? '' so explicit empty strings are preserved; also update the watch on
props.modelValue (the watcher referenced later in useEditor) to assign using ??
when syncing into the editor and add { immediate: true } to that watch so the
editor is initialized consistently with the modelValue.

In @packages/components/src/sender/composables/useSenderCore.ts:
- Around line 186-191: The setTimeout(0) is masking a race between
setMode('multiple') and the editor commands; replace this fragile delay by
awaiting the actual DOM/state update instead: call Vue's nextTick (or make
setMode return a Promise) and then invoke editorInstance.commands.splitBlock()
and editorInstance.commands.focus(), ensuring you await setMode/nextTick so mode
switching is complete before running splitBlock/focus instead of using
setTimeout.
- Around line 109-123: Replace hard-coded extension name strings in
useSenderCore.ts with shared constants: define e.g. EXTENSION_TEMPLATE and
EXTENSION_MENTION in a central constants module (or the existing extensions
registry) and import them into useSenderCore.ts; update the checks that call
editor.value.extensionManager.extensions.some((ext) => ext.name === 'template')
and the mention check to compare ext.name against these constants, and update
any other files using the same string literals to use the shared constants to
ensure compile-time consistency.

In @packages/components/src/sender/extensions/suggestion/extension.ts:
- Around line 28-42: The watcher created in onCreate (watch(this.options.items,
...)) is never stopped, causing a memory leak; capture the stop handle returned
by watch (e.g., const stopWatcher = watch(...)) and store it on the extension
instance, and implement onDestroy to call that stop handle (stopWatcher()) and
clear the reference so the Vue watcher is torn down when the extension is
destroyed or recreated; ensure you reference the same watcher created for
this.options.items and keep the existing dispatch logic with SuggestionPluginKey
unchanged.

In @packages/components/src/sender/extensions/template/block/plugins.ts:
- Around line 613-619: Deleting $from.nodeBefore first shifts document positions
so the subsequent tr.delete for $from.nodeAfter uses stale positions; to fix,
compute the deletion ranges up front (capture start and end positions for both
$from.nodeBefore and $from.nodeAfter before mutating the transaction) or delete
in reverse document order, or after the first tr.delete remap the second
delete's positions via tr.mapping.map(...). Update the block that checks
ZERO_WIDTH_CHAR and calls tr.delete to either gather both ranges and apply
deletes descending, or map the second delete using tr.mapping.map to ensure
correct positions are used.
- Around line 226-231: The condition event.key === 'Backspace' inside the inner
if is redundant because this code already lives within the outer if (event.key
=== 'Backspace' && selection.empty); remove the duplicate event.key ===
'Backspace' checks from the inner blocks that test ($from.pos === $from.end() &&
content.length === 1 && content !== ZERO_WIDTH_CHAR && event.key ===
'Backspace') and the similar occurrences around the handlers at the other two
spots (the checks near where content.length === 1 at the later branches),
leaving the other boolean checks intact (e.g., $from.pos === $from.end(),
content.length === 1, content !== ZERO_WIDTH_CHAR, selection.empty) so behavior
is unchanged.

In @packages/components/src/sender/extensions/template/commands.ts:
- Around line 122-156: The closure captures `blocks` before the setTimeout, so
when document changes (e.g., setTemplateData) run the callback reads stale data;
move the call to getAllTemplates(editor) inside the setTimeout callback so
template nodes/positions are recalculated at execution time, then compute
targetPos and create the selection with TextSelection.create(state.doc,
targetPos), set the transaction selection and dispatch/view.focus as before in
focusFirstTemplate to ensure accurate cursor placement.

In @packages/components/src/sender/extensions/template/select/extension.ts:
- Around line 44-47: The parseHTML handler currently calls JSON.parse on
element.getAttribute('data-options') which can throw on malformed JSON; update
parseHTML to read the attribute, wrap JSON.parse in a try-catch, return the
parsed array on success and fallback to an empty array on failure, and
optionally emit a warning (e.g., via console.warn or the module logger)
including the raw attribute and parse error to aid debugging; target the
parseHTML function and the element.getAttribute('data-options') usage when
making this change.

In @packages/components/src/sender/types/base.ts:
- Around line 152-167: The doc example in handleSubmit referencing
StructuredData incorrectly checks for and filters a non-existent 'template'
variant; update the example to use the actual variant name 'block' everywhere
(change the sample data element type, the guard from data[0]?.type ===
'template' to data[0]?.type === 'block', the filter to item.type === 'block',
and the example variable/log names from templates to blocks) so it matches the
real TemplateItem/StructuredData types.
- Around line 169-190: The example in handleSubmit misuses a type guard by
checking data[0]?.type === 'mention' on a union StructuredData (TemplateItem[] |
MentionStructuredItem[]), implying mixed element types; change the logic in
handleSubmit to perform a type-safe filter that narrows items to mentions (i.e.,
filter with a user-defined type predicate or Extract to assert item is
MentionStructuredItem when item.type === 'mention'), avoid assuming mixed
arrays, and only access mention-only properties like value after that narrowing
(use a conditional check like mentions.length > 0 before mapping value).
🧹 Nitpick comments (12)
packages/components/src/sender/extensions/mention/plugin.ts (2)

207-225: Unreachable return true after fallback selection.

Line 224 returns true even when no item was selected (e.g., filteredItems.length === 0). This prevents the default Enter/Tab behavior. If the panel is active but empty, consider allowing the default behavior or closing the panel instead.

♻️ Suggested improvement
         // 如果组件方法不可用,直接选择第一个提及项(fallback)
         if (pluginState.filteredItems.length > 0 && pluginState.range) {
           const firstItem = pluginState.filteredItems[0]
           insertMention(view, pluginState.range, firstItem)
           return true
         }

-        return true
+        // No items to select - allow default behavior
+        return false
       }

339-346: Cursor position assumes fixed node sizes.

The calculation range.from + 2 assumes the mention node has size 1 and the space has size 1. While this is typically true for atomic inline nodes in ProseMirror, consider using the actual node sizes for robustness:

♻️ More robust cursor positioning
   // 删除触发文本(包括触发字符)
   tr.delete(range.from, range.to)

   // 插入 mention 节点和空格
   tr.insert(range.from, [mentionNode, spaceNode])

-  // 设置光标到空格之后(mention 节点 + 空格 = +2)
-  const cursorPos = range.from + 2
+  // 设置光标到空格之后
+  const cursorPos = range.from + mentionNode.nodeSize + spaceNode.nodeSize
   tr.setSelection(TextSelection.create(tr.doc, cursorPos))
packages/components/src/sender/extensions/mention/utils.ts (1)

76-110: Consider handling mentions in all block types that support inline content.

The filter parent.type.name === 'paragraph' only collects mentions from paragraphs. Since the mention node is configured as group: 'inline', it can theoretically appear in any block type that accepts inline content (headings, list items, etc.). If the editor schema is extended with additional block types supporting mentions, this function would skip them. Consider checking the block type's ability to contain inline content rather than hardcoding paragraph filtering.

packages/components/src/sender/extensions/template/utils.ts (1)

37-38: Consider using string literal or replaceAll to simplify regex pattern.

Static analysis flags the dynamic RegExp construction as a potential ReDoS risk. While this is a false positive (since ZERO_WIDTH_CHAR is a constant literal), using a static pattern or replaceAll improves clarity and silences the warning.

♻️ Cleaner alternatives

Option 1: Use literal regex pattern

-      const content = (node.textContent || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '')
+      const content = (node.textContent || '').replace(/\u200B/g, '')

Option 2: Use replaceAll (ES2021+)

-      const content = (node.textContent || '').replace(new RegExp(ZERO_WIDTH_CHAR, 'g'), '')
+      const content = (node.textContent || '').replaceAll(ZERO_WIDTH_CHAR, '')

Apply the same fix at line 53 for text nodes.

Also applies to: 52-53

packages/components/src/sender-actions/action-button/index.vue (1)

22-48: Refactor to eliminate code duplication.

The button element is rendered twice with nearly identical markup (lines 31-41 and lines 44-48), differing only in the tooltip wrapper. This violates the DRY principle and makes maintenance harder.

♻️ Proposed refactor to extract button into a reusable template
 <script setup lang="ts">
 import { computed } from 'vue'
 import { TinyTooltip } from '@opentiny/vue'
 import type { ActionButtonProps } from '../types/common'
 import { normalizeTooltipContent } from '../utils/tooltip'
 
 const props = withDefaults(defineProps<ActionButtonProps>(), {
   disabled: false,
   active: false,
   size: 32,
   tooltipPlacement: 'top',
 })
 
 const tooltipRenderFn = computed(() => normalizeTooltipContent(props.tooltip))
 
 const sizeStyle = computed(() => {
   const size = typeof props.size === 'number' ? `${props.size}px` : props.size
   return { fontSize: size }
 })
 </script>
 
 <template>
+  <!-- 提取共享的按钮模板 -->
+  <template #button-content>
+    <button
+      :class="['tr-action-button', { active: props.active }]"
+      :disabled="props.disabled"
+      @focus.capture="(event: FocusEvent) => event.stopPropagation()"
+    >
+      <slot name="icon">
+        <component :is="props.icon" :style="sizeStyle" />
+      </slot>
+    </button>
+  </template>
+
   <tiny-tooltip
     v-if="props.tooltip"
     :render-content="tooltipRenderFn"
     :placement="props.tooltipPlacement"
     effect="light"
     :visible-arrow="false"
     popper-class="tr-action-button-tooltip-popper"
   >
-    <button
-      :class="['tr-action-button', { active: props.active }]"
-      :disabled="props.disabled"
-      @focus.capture="(event: FocusEvent) => event.stopPropagation()"
-    >
-      <!-- 优先使用插槽,如果没有插槽则使用 icon prop -->
-      <slot name="icon">
-        <component :is="props.icon" :style="sizeStyle" />
-      </slot>
-    </button>
+    <template v-slot:default>
+      <slot name="button-content" />
+    </template>
   </tiny-tooltip>
 
-  <!-- 无 tooltip 时直接渲染按钮 -->
-  <button v-else :class="['tr-action-button', { active: props.active }]" :disabled="props.disabled">
-    <slot name="icon">
-      <component :is="props.icon" :style="sizeStyle" />
-    </slot>
-  </button>
+  <slot v-else name="button-content" />
 </template>

Note: Vue 3 doesn't support named template definitions in the same component. A better approach would be to extract the button to a separate render function or use a v-bind approach:

<script setup lang="ts">
import { computed } from 'vue'
import { TinyTooltip } from '@opentiny/vue'
import type { ActionButtonProps } from '../types/common'
import { normalizeTooltipContent } from '../utils/tooltip'

const props = withDefaults(defineProps<ActionButtonProps>(), {
  disabled: false,
  active: false,
  size: 32,
  tooltipPlacement: 'top',
})

const tooltipRenderFn = computed(() => normalizeTooltipContent(props.tooltip))

const sizeStyle = computed(() => {
  const size = typeof props.size === 'number' ? `${props.size}px` : props.size
  return { fontSize: size }
})

const buttonAttrs = computed(() => ({
  class: ['tr-action-button', { active: props.active }],
  disabled: props.disabled,
  onFocusCapture: (event: FocusEvent) => event.stopPropagation()
}))
</script>

<template>
  <component
    :is="props.tooltip ? TinyTooltip : 'div'"
    v-bind="props.tooltip ? {
      renderContent: tooltipRenderFn,
      placement: props.tooltipPlacement,
      effect: 'light',
      visibleArrow: false,
      popperClass: 'tr-action-button-tooltip-popper'
    } : {}"
  >
    <button v-bind="buttonAttrs">
      <slot name="icon">
        <component :is="props.icon" :style="sizeStyle" />
      </slot>
    </button>
  </component>
</template>
packages/components/src/sender/types/base.ts (1)

125-133: Deprecated re-export may cause confusion.

Re-exporting MentionItem from this base types file with a deprecation notice creates an unnecessary import path. Since it's deprecated, consider whether this re-export serves a genuine backward compatibility need or if it should be removed to encourage direct imports from the extensions module.

packages/components/src/sender-actions/voice-button/useSpeechHandler.ts (1)

1-8: Align error handling between initial start and restart path

The new try/catch around handler.start(callbacks) in the normal start path is a good improvement, but the restart path still calls handler.start(callbacks) without protection:

if (speechState.isRecording) {
  handler.stop()
  speechState.isRecording = false
  setTimeout(() => {
    handler.start(callbacks)  // no try/catch here
  }, 200)
  return
}

If a custom SpeechHandler throws synchronously on start, this branch will still surface an uncaught error.

Consider mirroring the same error handling in the restart path:

Suggested fix
-    // 短暂延迟后重新开始
-    setTimeout(() => {
-      handler.start(callbacks)
-    }, 200)
+    // 短暂延迟后重新开始
+    setTimeout(() => {
+      try {
+        handler.start(callbacks)
+      } catch (error) {
+        speechState.error = error instanceof Error ? error : new Error('启动失败')
+        optionsRef.value.onError?.(speechState.error)
+      }
+    }, 200)

This keeps error reporting consistent regardless of whether recording is started fresh or restarted.

Also applies to: 20-22, 30-55, 65-90

packages/components/src/sender/composables/useSenderCore.ts (1)

215-223: Another setTimeout(0) workaround for overflow checking.

Similar to the mode-switching issue, using setTimeout(0) to defer checkOverflow after content changes indicates a race condition with DOM updates. While less critical than the keyboard handler case, consider using nextTick for more predictable timing.

💡 Suggested alternative
  watch(
    () => editor.value?.state.doc.content,
    () => {
-     setTimeout(() => {
+     nextTick(() => {
        checkOverflow()
-     }, 0)
+     })
    },
    { deep: true },
  )
packages/components/src/sender/types/context.ts (1)

145-157: Clarify setContent/getContent value shape in docs vs types

Docs say setContent accepts “HTML 或 JSON”,but the signature only allows string, and getContent is documented as returning HTML. If you intend to support TipTap’s JSON document objects, consider widening the type (e.g. string | JSONContent) or narrowing the comments so they consistently describe string-only usage.

packages/components/src/sender/extensions/suggestion/plugin.ts (1)

102-116: insertSuggestion currently replaces the entire document, ignoring range

Right now insertSuggestion ignores the range argument and always does editor.commands.setContent(item.content), so selecting a suggestion replaces the whole doc. That matches the current “global mode” where range is { from: 0, to: doc.size }, but it will be wrong if you later support character-triggered or partial-range suggestions (as hinted by the SuggestionState.range docs).

Consider switching to something like editor.commands.insertContentAt(range, item.content) (or an equivalent command) so the implementation already respects range and won’t silently misbehave once non-global modes are introduced.

packages/components/src/sender/index.type.ts (2)

160-161: Consider tightening the extensions type for better type safety.

The Extension[] | any[] union allows arbitrary arrays to bypass type checking. While this provides flexibility, it sacrifices type safety and could lead to runtime errors if invalid extensions are passed.

Consider either:

  • Removing any[] to enforce type safety (preferred)
  • Or documenting why any[] is necessary if there are specific compatibility requirements
♻️ Proposed refactor for stricter typing
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  extensions?: Extension[] | any[]
+  extensions?: Extension[]

337-337: Improve type safety: specify the editor type instead of unknown.

Typing the editor as unknown forces consumers to cast before accessing editor methods, reducing both type safety and developer experience. Consider typing it as Editor from @tiptap/core or a more specific interface that exposes the public API.

♻️ Suggested improvement

Add the Editor import at the top:

import type { Extension, Editor } from '@tiptap/core'

Then update the slot definition:

-  content?: (props: { editor: unknown }) => unknown
+  content?: (props: { editor: Editor }) => unknown
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c307275 and d8a7217.

📒 Files selected for processing (91)
  • packages/components/package.json
  • packages/components/src/index.ts
  • packages/components/src/sender-actions/action-button/index.vue
  • packages/components/src/sender-actions/clear-button/index.vue
  • packages/components/src/sender-actions/default-actions/index.vue
  • packages/components/src/sender-actions/index.ts
  • packages/components/src/sender-actions/index.type.ts
  • packages/components/src/sender-actions/submit-button/index.vue
  • packages/components/src/sender-actions/types/common.ts
  • packages/components/src/sender-actions/types/index.ts
  • packages/components/src/sender-actions/types/tooltip.ts
  • packages/components/src/sender-actions/upload-button/index.type.ts
  • packages/components/src/sender-actions/upload-button/index.vue
  • packages/components/src/sender-actions/utils/tooltip.ts
  • packages/components/src/sender-actions/voice-button/index.type.ts
  • packages/components/src/sender-actions/voice-button/index.vue
  • packages/components/src/sender-actions/voice-button/speech.types.ts
  • packages/components/src/sender-actions/voice-button/useSpeechHandler.ts
  • packages/components/src/sender-actions/voice-button/webSpeechHandler.ts
  • packages/components/src/sender-actions/word-counter/index.vue
  • packages/components/src/sender-compat/index.ts
  • packages/components/src/sender-compat/index.type.ts
  • packages/components/src/sender-compat/index.vue
  • packages/components/src/sender/components/ActionButtons.vue
  • packages/components/src/sender/components/Block.vue
  • packages/components/src/sender/components/TemplateEditor.vue
  • packages/components/src/sender/components/editor-content/index.vue
  • packages/components/src/sender/components/footer/index.vue
  • packages/components/src/sender/components/global.d.ts
  • packages/components/src/sender/components/layouts/MultiLineLayout.vue
  • packages/components/src/sender/components/layouts/SingleLineLayout.vue
  • packages/components/src/sender/composables/index.ts
  • packages/components/src/sender/composables/useAutoSize.ts
  • packages/components/src/sender/composables/useEditor.ts
  • packages/components/src/sender/composables/useInputHandler.ts
  • packages/components/src/sender/composables/useKeyboardHandler.ts
  • packages/components/src/sender/composables/useKeyboardShortcuts.ts
  • packages/components/src/sender/composables/useModeSwitch.ts
  • packages/components/src/sender/composables/useSenderCore.ts
  • packages/components/src/sender/composables/useSlotScope.ts
  • packages/components/src/sender/composables/useSuggestionHandler.ts
  • packages/components/src/sender/composables/useUndoRedo.ts
  • packages/components/src/sender/context/index.ts
  • packages/components/src/sender/context/types.ts
  • packages/components/src/sender/extensions/index.ts
  • packages/components/src/sender/extensions/mention/commands.ts
  • packages/components/src/sender/extensions/mention/components/mention-list.vue
  • packages/components/src/sender/extensions/mention/components/mention-view.vue
  • packages/components/src/sender/extensions/mention/extension.ts
  • packages/components/src/sender/extensions/mention/index.less
  • packages/components/src/sender/extensions/mention/index.ts
  • packages/components/src/sender/extensions/mention/plugin.ts
  • packages/components/src/sender/extensions/mention/types.ts
  • packages/components/src/sender/extensions/mention/utils.ts
  • packages/components/src/sender/extensions/suggestion/extension.ts
  • packages/components/src/sender/extensions/suggestion/index.less
  • packages/components/src/sender/extensions/suggestion/index.ts
  • packages/components/src/sender/extensions/suggestion/plugin.ts
  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
  • packages/components/src/sender/extensions/suggestion/types.ts
  • packages/components/src/sender/extensions/suggestion/utils/filter.ts
  • packages/components/src/sender/extensions/suggestion/utils/highlight.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/extensions/template/block/plugins.ts
  • packages/components/src/sender/extensions/template/block/template-block-view.vue
  • packages/components/src/sender/extensions/template/commands.ts
  • packages/components/src/sender/extensions/template/extension.ts
  • packages/components/src/sender/extensions/template/index.ts
  • packages/components/src/sender/extensions/template/select/dropdown-manager.ts
  • packages/components/src/sender/extensions/template/select/extension.ts
  • packages/components/src/sender/extensions/template/select/plugins.ts
  • packages/components/src/sender/extensions/template/select/template-select-view.vue
  • packages/components/src/sender/extensions/template/types.ts
  • packages/components/src/sender/extensions/template/utils.ts
  • packages/components/src/sender/extensions/utils/id-generator.ts
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/extensions/utils/position.ts
  • packages/components/src/sender/index.less
  • packages/components/src/sender/index.ts
  • packages/components/src/sender/index.type.ts
  • packages/components/src/sender/index.vue
  • packages/components/src/sender/types/base.ts
  • packages/components/src/sender/types/components.ts
  • packages/components/src/sender/types/composables.ts
  • packages/components/src/sender/types/context.ts
  • packages/components/src/sender/types/editor.type.ts
  • packages/components/src/sender/types/slots.ts
  • packages/components/src/styles/components/index.css
  • packages/components/src/styles/components/sender.less
  • packages/components/src/styles/variables.css
  • packages/components/vite.config.ts
💤 Files with no reviewable changes (9)
  • packages/components/src/sender/components/ActionButtons.vue
  • packages/components/src/sender/composables/useSuggestionHandler.ts
  • packages/components/src/sender/components/global.d.ts
  • packages/components/src/sender/components/TemplateEditor.vue
  • packages/components/src/sender/composables/useUndoRedo.ts
  • packages/components/src/sender/types/editor.type.ts
  • packages/components/src/sender/components/Block.vue
  • packages/components/src/sender/composables/useInputHandler.ts
  • packages/components/src/sender/composables/useKeyboardHandler.ts
🚧 Files skipped from review as they are similar to previous changes (43)
  • packages/components/src/sender/extensions/suggestion/index.less
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/composables/useSlotScope.ts
  • packages/components/src/sender-actions/voice-button/webSpeechHandler.ts
  • packages/components/src/sender/composables/useKeyboardShortcuts.ts
  • packages/components/src/sender/extensions/template/index.ts
  • packages/components/src/sender-actions/upload-button/index.type.ts
  • packages/components/src/sender-actions/voice-button/index.vue
  • packages/components/src/sender/extensions/mention/index.less
  • packages/components/src/sender-actions/index.type.ts
  • packages/components/src/sender/types/components.ts
  • packages/components/src/sender-compat/index.ts
  • packages/components/src/sender/extensions/suggestion/index.ts
  • packages/components/src/sender/extensions/mention/components/mention-view.vue
  • packages/components/src/sender/extensions/mention/commands.ts
  • packages/components/src/sender/context/index.ts
  • packages/components/src/sender/components/editor-content/index.vue
  • packages/components/src/sender/components/footer/index.vue
  • packages/components/src/sender-compat/index.type.ts
  • packages/components/src/sender/extensions/template/select/plugins.ts
  • packages/components/src/sender-actions/default-actions/index.vue
  • packages/components/src/sender/extensions/mention/index.ts
  • packages/components/src/sender-actions/clear-button/index.vue
  • packages/components/src/sender/extensions/suggestion/types.ts
  • packages/components/src/sender/context/types.ts
  • packages/components/src/sender-actions/types/tooltip.ts
  • packages/components/src/sender/extensions/index.ts
  • packages/components/src/sender/extensions/suggestion/utils/filter.ts
  • packages/components/src/sender-actions/utils/tooltip.ts
  • packages/components/src/sender-actions/voice-button/speech.types.ts
  • packages/components/src/sender-actions/types/common.ts
  • packages/components/src/sender/extensions/mention/components/mention-list.vue
  • packages/components/src/sender/extensions/template/select/dropdown-manager.ts
  • packages/components/src/sender/extensions/template/types.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/composables/useAutoSize.ts
  • packages/components/src/sender/extensions/mention/types.ts
  • packages/components/src/sender-actions/types/index.ts
  • packages/components/src/sender/types/composables.ts
  • packages/components/src/styles/components/index.css
  • packages/components/src/sender/composables/useModeSwitch.ts
  • packages/components/src/sender-actions/voice-button/index.type.ts
  • packages/components/src/styles/variables.css
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-06-18T09:29:47.974Z
Learnt from: SonyLeo
Repo: opentiny/tiny-robot PR: 119
File: packages/components/src/attachments/index.less:213-213
Timestamp: 2025-06-18T09:29:47.974Z
Learning: 在 packages/components/src/attachments/index.less 中,.tr-file-card__close 的背景色使用了硬编码的 rgb(194, 194, 194),但这个UI元素(关闭按钮)将会被直接替换为图标,所以不需要抽取为CSS变量。

Applied to files:

  • packages/components/src/styles/components/sender.less
  • packages/components/src/sender/index.less
  • packages/components/src/sender/index.vue
📚 Learning: 2025-06-25T07:04:18.791Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 123
File: packages/components/src/bubble/message/Message.vue:22-40
Timestamp: 2025-06-25T07:04:18.791Z
Learning: In the Message component (packages/components/src/bubble/message/Message.vue), the renderer resolution is intentionally not reactive to type changes - the component is designed to work with a fixed type that doesn't change after initialization.

Applied to files:

  • packages/components/src/sender/index.vue
📚 Learning: 2025-05-27T03:35:11.008Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 59
File: packages/components/src/suggestion-popover/index.vue:131-133
Timestamp: 2025-05-27T03:35:11.008Z
Learning: In the SuggestionPopover component (packages/components/src/suggestion-popover/index.vue), the click handler can be bound unconditionally because the `show` computed property has a custom setter that prevents state mutations when `props.trigger === 'manual'`. This design centralizes trigger mode logic in the computed property rather than requiring conditional checks in event handlers.

Applied to files:

  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
🧬 Code graph analysis (9)
packages/components/src/index.ts (2)
packages/components/src/suggestion-pills/index.ts (1)
  • SuggestionPillButton (25-25)
packages/components/src/attachments/index.type.ts (1)
  • ActionButton (45-49)
packages/components/src/sender/extensions/utils/position.ts (1)
packages/components/src/sender/extensions/utils/index.ts (1)
  • findTextRange (6-6)
packages/components/src/sender/composables/useSenderCore.ts (10)
packages/components/src/sender/context/types.ts (1)
  • SenderContext (8-8)
packages/components/src/sender/index.type.ts (1)
  • SenderProps (32-227)
packages/components/src/sender/composables/index.ts (2)
  • useEditor (5-5)
  • useModeSwitch (7-7)
packages/components/src/sender/composables/useEditor.ts (1)
  • useEditor (26-131)
packages/components/src/sender/extensions/template/utils.ts (2)
  • getTextWithTemplates (20-23)
  • getTemplateStructuredData (30-71)
packages/components/src/sender/extensions/mention/utils.ts (2)
  • getMentionStructuredData (76-110)
  • getTextWithMentions (41-61)
packages/components/src/sender/composables/useModeSwitch.ts (1)
  • useModeSwitch (11-99)
packages/components/src/sender/composables/useKeyboardShortcuts.ts (1)
  • useKeyboardShortcuts (16-78)
packages/components/src/sender/extensions/mention/plugin.ts (1)
  • MentionPluginKey (24-24)
packages/components/src/sender/extensions/suggestion/plugin.ts (1)
  • SuggestionPluginKey (21-21)
packages/components/src/sender/extensions/mention/plugin.ts (5)
packages/components/src/sender/extensions/mention/types.ts (2)
  • MentionSuggestionState (109-129)
  • MentionItem (15-39)
packages/components/src/sender/extensions/suggestion/index.ts (1)
  • suggestion (32-40)
packages/components/src/sender/extensions/utils/position.ts (1)
  • findTextRange (26-65)
packages/components/src/sender/extensions/suggestion/types.ts (1)
  • EditorState (263-269)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
  • generateId (17-19)
packages/components/src/sender/index.ts (1)
packages/components/src/index.ts (3)
  • install (101-106)
  • Sender (136-136)
  • Sender (137-137)
packages/components/src/sender/extensions/suggestion/plugin.ts (2)
packages/components/src/sender/extensions/suggestion/types.ts (3)
  • SuggestionState (83-128)
  • SuggestionOptions (135-249)
  • SuggestionItem (48-76)
packages/components/src/sender/extensions/suggestion/utils/filter.ts (1)
  • syncAutoComplete (25-57)
packages/components/src/sender/types/base.ts (3)
packages/components/src/sender/extensions/mention/types.ts (1)
  • MentionStructuredItem (46-55)
packages/components/src/sender-actions/types/common.ts (2)
  • TooltipContent (5-5)
  • TooltipPlacement (12-24)
packages/components/src/sender-actions/types/tooltip.ts (1)
  • TooltipContent (8-8)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
packages/components/src/sender/extensions/utils/index.ts (1)
  • generateId (5-5)
packages/components/src/sender/extensions/suggestion/extension.ts (4)
packages/components/src/sender/extensions/suggestion/index.ts (2)
  • Suggestion (12-12)
  • SuggestionPluginKey (13-13)
packages/components/src/sender/extensions/suggestion/types.ts (1)
  • SuggestionOptions (135-249)
packages/components/src/sender/index.ts (1)
  • SuggestionOptions (58-58)
packages/components/src/sender/extensions/suggestion/plugin.ts (2)
  • SuggestionPluginKey (21-21)
  • createSuggestionPlugin (36-506)
🪛 ast-grep (0.40.4)
packages/components/src/sender/extensions/template/utils.ts

[warning] 37-37: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 52-52: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🪛 Biome (2.1.2)
packages/components/src/sender/extensions/template/block/plugins.ts

[error] 423-424: This branch can never execute. Its condition is a duplicate or covered by previous conditions in the if-else-if chain.

(lint/suspicious/noDuplicateElseIf)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: e2e-test / e2e-test
🔇 Additional comments (38)
packages/components/vite.config.ts (1)

46-63: Approved: TipTap externals configuration is complete and correct.

All nine @tiptap peerDependencies are properly externalized in the configuration. The regex pattern /^@tiptap\/pm(\/.*)?$/ correctly matches the imported ProseMirror submodules (@tiptap/pm/view, @tiptap/pm/state). Verification confirms all actual imports in the source files (sender composables and extensions) are covered by the externals declaration.

packages/components/package.json (3)

22-22: LGTM! Trailing comma improves diff hygiene.

The added trailing comma is a good practice that reduces diff noise when adding new dependencies in the future.


23-31: The TipTap peer dependencies at version ^3.11.0 are valid, compatible with Vue ^3.3.11, and have no known vulnerabilities. The semantic version constraint will permit updates to the latest stable 3.x releases (currently 3.13.0 as of December 2025) when dependencies are refreshed.


34-34: No issues found with @floating-ui/dom ^1.6.0.

Version 1.6.0 exists and has no known security vulnerabilities. The dependency is appropriately versioned for production use.

packages/components/src/sender/extensions/utils/position.ts (2)

1-25: LGTM! Documentation is clear and comprehensive.

The file-level and function-level documentation clearly explains the purpose and usage of this utility. The JSDoc includes parameter descriptions, return type, and a practical example.


26-65: Well-implemented utility function with sound logic.

The position calculation and query extraction logic are correct:

  • Uses textBetween with the standard ProseMirror object replacement character (\ufffc)
  • Correctly finds the last trigger occurrence using lastIndexOf
  • Properly calculates absolute positions: from = $from.start() + lastCharIndex, to = $from.pos
  • The range includes the trigger character, which is correct for mention/suggestion replacement behavior

The edge cases are properly handled (empty selection, non-textblock, no trigger found, space validation).

packages/components/src/sender/extensions/mention/utils.ts (2)

13-28: LGTM!

The getMentions function correctly traverses the document and extracts mention node attributes. The use of any type with the eslint-disable comment is acceptable for ProseMirror node traversal where precise typing is difficult.


41-61: LGTM!

The function correctly retrieves the configured trigger character from the mention extension options with a sensible default, and properly handles both mention and text nodes during traversal.

packages/components/src/sender/extensions/utils/id-generator.ts (1)

17-19: LGTM!

The ID generator is appropriate for UI element identification. The combination of timestamp and 7 random base-36 characters provides sufficient uniqueness for mention/template IDs without requiring cryptographic randomness.

packages/components/src/sender/extensions/mention/extension.ts (2)

30-69: LGTM!

The attribute definitions correctly handle parsing from and rendering to HTML data attributes. The conditional returns for empty attributes prevent unnecessary attribute pollution in the rendered HTML.


71-92: LGTM!

The HTML parsing and rendering configuration is well-structured. Using data-mention as the parsing selector and merging attributes with mergeAttributes follows TipTap conventions correctly.

packages/components/src/sender/extensions/mention/plugin.ts (5)

36-56: LGTM!

The filter function correctly handles both label and value matching with case-insensitive comparison. Returning all items when query is empty is the expected behavior for initial display.


81-124: LGTM!

The state management correctly handles meta-driven close events and document/selection changes. The early return when neither docChanged nor selectionSet is true prevents unnecessary recomputation.


144-175: Backspace handling converts mention to trigger character — verify this is intended UX.

When the user presses Backspace immediately after a mention, the mention is deleted and replaced with the trigger character (e.g., @). This is a two-step delete pattern. Ensure this UX is intentional and documented, as some users might expect a single Backspace to fully remove the mention.


275-294: Potential race condition if decoration element is not yet rendered.

The querySelector('.mention-trigger') relies on the decoration being rendered synchronously before this update call. If the DOM update is deferred, referenceElement could be null, and the popup won't be positioned correctly. Consider adding a fallback or using requestAnimationFrame if positioning issues arise.


310-316: LGTM!

The destroy method correctly cleans up all resources: the autoUpdate subscription, the Vue component, and the popup element. This prevents memory leaks when the plugin is destroyed.

packages/components/src/sender/extensions/template/extension.ts (1)

1-25: LGTM! Clean extension aggregator.

The Template extension properly aggregates TemplateBlock and TemplateSelect, passes configuration options, and exposes the unified command set. The implementation follows TipTap patterns correctly.

packages/components/src/sender/extensions/template/block/template-block-view.vue (1)

1-77: LGTM! Well-implemented NodeView component.

The template block view is cleanly implemented with proper TypeScript types, correct NodeView integration, and thoughtful styling that handles both normal and empty states.

packages/components/src/sender/extensions/template/select/template-select-view.vue (1)

1-390: LGTM! Comprehensive template select implementation.

The template select component is well-architected with:

  • Proper state management and ProseMirror plugin synchronization
  • Robust keyboard navigation with wrapping and scrolling
  • Correct Floating UI integration with cleanup
  • Appropriate lifecycle management and event handling
  • Clean separation of scoped and global styles
packages/components/src/sender/extensions/template/utils.ts (1)

43-51: Incomplete data extraction for templateSelect items.

As noted in a previous review, the current implementation only extracts the selected option's value but discards available metadata. To preserve the full template state, consider storing the complete select configuration.

♻️ Suggested enhancement
       } else if (node.type.name === 'templateSelect') {
-        // 获取选中的值
         const selectedOption = node.attrs.options.find((opt: { value: string }) => opt.value === node.attrs.value)
-        const content = selectedOption?.value || ''
 
         items.push({
           type: 'select',
-          content,
+          content: selectedOption?.value || '',
+          options: node.attrs.options,
+          value: node.attrs.value,
+          placeholder: node.attrs.placeholder,
         })

Ensure the TemplateItem['select'] type in index.type.ts accommodates these fields.

Likely an incorrect or invalid review comment.

packages/components/src/sender-actions/action-button/index.vue (1)

34-34: Review focus event handling strategy.

The focus event is stopped from propagating to parent handlers using @focus.capture with stopPropagation(). This might prevent legitimate parent focus handlers from executing, potentially breaking focus management in complex layouts.

Consider whether this is truly necessary or if there's a more targeted solution.

packages/components/src/sender/composables/index.ts (1)

1-9: LGTM! Clean barrel export.

The file provides a centralized entry point for sender composables with proper TypeScript type exports. No issues detected.

packages/components/src/styles/components/sender.less (1)

1-69: LGTM! Well-structured theming system.

The LESS mixin correctly defines CSS custom properties for sender theming with both normal and compact size variants. All referenced global variables (--tr-container-bg-default, --tr-text-primary, --tr-text-tertiary, --tr-container-bg-hover) are properly defined in the project's global variables file with support for both light and dark themes. The use of maps and the each() function to generate variables is clean and maintainable.

packages/components/src/sender/types/slots.ts (1)

1-86: LGTM! Clean and well-documented slot scope interface.

The SenderSlotScope interface provides a clear contract for slot-based components with appropriate typing and comprehensive JSDoc documentation. @tiptap/core is properly declared as a peer dependency in package.json.

packages/components/src/sender-actions/word-counter/index.vue (1)

1-28: Word counter visibility and styling look consistent

The computed show guard and use of context values (characterCount, maxLength, isOverLimit, showWordLimit) are wired correctly, and the styles follow the sender CSS-variable scheme. No issues from this file alone.

packages/components/src/sender/index.less (1)

6-19: Small size token mapping is clean and consistent

The .tr-sender--small block cleanly remaps all relevant layout and button variables to their -small counterparts and keeps size concerns centralized. Looks good.

packages/components/src/sender/components/layouts/MultiLineLayout.vue (1)

1-90: Multi-line layout wiring looks correct

Context, slotScope, and EditorContent are composed cleanly, header/footer slots are flexible, and the multiline ProseMirror styling matches expectations for a textarea-like editor. No issues noticed here.

packages/components/src/sender-actions/index.ts (1)

1-23: LGTM! Clean barrel export structure.

The centralized export pattern is well-organized and properly documented. All component and utility exports follow a consistent structure.

packages/components/src/sender/extensions/suggestion/utils/highlight.ts (1)

1-188: LGTM! Excellent documentation improvements.

The updated JSDoc with concrete examples for all three highlight modes significantly improves the maintainability of this utility. The type import path consolidation aligns with the broader refactoring goals.

packages/components/src/sender/composables/useSenderCore.ts (2)

166-172: Plugin state checks assume plugins are correctly initialized.

Direct calls to getState on MentionPluginKey, SuggestionPluginKey, and TemplateSelectDropdownPluginKey assume these plugins are registered. If a plugin is not present, getState may return undefined, leading to runtime errors when accessing .active or .isOpen.

Likely an incorrect or invalid review comment.


156-205: Add cleanup for dynamically injected keyboard handlers.

The watch that injects editorProps.handleKeyDown does not provide cleanup when the editor instance changes or the component unmounts. Without explicit cleanup, handlers may leak between editor instances.

Likely an incorrect or invalid review comment.

packages/components/src/index.ts (1)

16-31: SenderCompat and sender-action components are correctly wired into the public surface

The additions cleanly integrate SenderCompat and the action components into both the plugin components array and the named exports, and the useSenderContext re-export from ./sender keeps the context API discoverable. No issues from an API-surface or install perspective.

Also applies to: 68-98, 139-167

packages/components/src/sender/index.vue (1)

18-71: Core/context + layout split in Sender.vue looks clean

The component now focuses solely on wiring useSenderCore into layout selection and slot passthrough, with mode/size/disabled styling driven from context. The separation into SingleLineLayout/MultiLineLayout and the defineExpose(expose) pattern read cleanly and should make future core changes much easier to contain.

Also applies to: 75-137

packages/components/src/sender/extensions/suggestion/suggestion-list.vue (1)

41-87: Suggestion list callback API and UI behavior look solid

The move from emits to optional callback props (onSelect/onMouseEnter/onMouseLeave) makes the list easier to embed via VueRenderer, and the keyboard index watcher + scroll-into-view logic is straightforward. The click handler passing item.content into onSelect matches how the plugin resolves the selected item, and the updated styling/transition hooks are consistent with the rest of the design.

Also applies to: 105-137, 139-217

packages/components/src/sender/index.ts (1)

14-35: Composite Sender export and type surface are well-structured

Augmenting SenderComponent with install, static extensions (Mention/Suggestion/Template), and helper functions while re-exporting the core and extension types plus useSenderContext gives consumers a single, coherent entrypoint. This aligns nicely with how SenderCompat and root index wire things up.

Also applies to: 37-62

packages/components/src/sender/index.type.ts (3)

1-15: LGTM! Clean modular type organization.

The import and re-export structure effectively centralizes the type surface while maintaining modularity. The separation of concerns (base, composables, components, context, slots) will improve maintainability.


18-23: LGTM!

The DefaultConfig interface provides a clean contract for default configuration values.


236-311: LGTM! Well-designed event interface.

The emit signatures are well-structured, especially the enhanced submit event that provides both plain text and optional structured data. The JSDoc examples clearly demonstrate the intended usage patterns.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In @packages/components/src/sender/extensions/mention/commands.ts:
- Around line 33-45: The deleteMention command currently modifies the
transaction but never dispatches it and doesn't validate the id; update the
deleteMention function in commands.ts to accept the optional dispatch parameter
(per TipTap v3 command signature), validate that id is provided and non-empty
before scanning, and if a mention node is found and tr.delete was called, invoke
dispatch(tr) to apply the transaction (otherwise return false). Ensure the
command returns a boolean indicating success/failure and uses the existing
NODE_TYPE_NAMES.MENTION reference and the tr/state parameters as before.

In @packages/components/src/sender/extensions/mention/extension.ts:
- Around line 82-93: The renderHTML method accesses node.attrs.id,
node.attrs.label, node.attrs.value and concatenates this.options.char with
node.attrs.label without null checks; update renderHTML to defensively handle
null/undefined by coalescing each attribute to an empty string (or a safe
default) before casting/using them, e.g., compute local variables like id =
node.attrs.id ?? '' (label = node.attrs.label ?? '' value = node.attrs.value ??
'' and char = this.options.char ?? ''), use those variables when building
mergeAttributes and when concatenating the displayed text so no "null" or
invalid attributes are emitted.

In @packages/components/src/sender/extensions/template/block/plugins.ts:
- Around line 116-588: The function keyboardNavigationPlugin has very high
cyclomatic complexity; extract the large inline conditionals into focused
handler functions (e.g., handleArrowLeft, handleArrowRight,
handleArrowsInTemplateBlock, handleBackspace, handleBackspaceSelection,
handleDelete, handleDeleteSelection) that accept (view, event, state, selection,
$from, dispatch) and return a boolean, move the corresponding code blocks into
those functions, and then simplify handleKeyDown to call each handler based on
isKey/isAnyKey checks (returning the handler's boolean) so
keyboardNavigationPlugin only composes handlers and remains concise and
testable.
- Around line 419-423: The inner isKey(event, 'DELETE') checks are redundant
because the code is already inside the outer if (isKey(event, 'DELETE') &&
selection.empty) block; remove the isKey(event, 'DELETE') sub-conditions at the
places that check $from.pos === $from.start() && content.length === 1 && content
!== ZERO_WIDTH_CHAR, the later condition around content length and
ZERO_WIDTH_CHAR, and the third similar check (the ones that currently include
isKey(event, 'DELETE')), keeping the other predicates ($from.pos, $from.start(),
content length, ZERO_WIDTH_CHAR, selection.empty) intact so the logic and early
returns remain unchanged.
- Around line 228-232: The inner checks for isKey(event, 'BACKSPACE') are
redundant because the surrounding guard already ensures isKey(event,
'BACKSPACE') && selection.empty; remove the repeated isKey(event, 'BACKSPACE')
conditions from the inner if expressions (the ones that test $from.pos ===
$from.end() && content.length === 1 && content !== ZERO_WIDTH_CHAR, and the
similar checks later) so they only rely on the existing outer isKey(event,
'BACKSPACE') && selection.empty guard; keep the other conditions ($from.pos ===
$from.end(), content.length === 1, content !== ZERO_WIDTH_CHAR, ZERO_WIDTH_CHAR,
selection.empty, etc.) intact to preserve behavior.
- Around line 373-382: The else-if branch using the same check beforeNode.text
=== ZERO_WIDTH_CHAR is unreachable because an earlier if already tests that
exact condition; either remove this block or change its condition to the
intended complementary check so it can ever run. Locate the block with symbols
index, $from.pos, beforeNode.text, ZERO_WIDTH_CHAR, selection, dispatch,
state.tr.delete, and event.preventDefault in plugins.ts and either delete the
unreachable else-if, or replace its condition with the correct predicate (for
example the logical complement or a check against a different node/character
that matches the intended logic) so the deletion and event.preventDefault() code
executes only when appropriate.

In @packages/components/src/sender/extensions/template/select/extension.ts:
- Around line 56-58: The renderHTML arrow functions (renderHTML) call
JSON.stringify on attributes.options without protection; wrap those
JSON.stringify calls in try/catch similar to parseHTML, log or report the caught
error (include error message and context) and fall back to a safe serializable
value (e.g., '{}' or 'null') for the 'data-options' attribute so serialization
failures (circular refs/non-serializable values) don’t throw and break
rendering.

In @packages/components/src/sender/extensions/template/select/plugins.ts:
- Around line 75-113: The deletion loop in
selectZeroWidthPlugin.appendTransaction collects todoPositions then deletes in
forward order, which causes later positions to shift and produce incorrect
deletions; change the deletion application to process todoPositions in reverse
order (or sort by position descending) before calling tr.delete (the entries in
todoPositions and use tr.delete(pos, pos + 1)) so earlier deletions do not
invalidate later offsets.
🧹 Nitpick comments (9)
packages/components/src/sender-actions/upload-button/index.vue (1)

33-59: Internationalize error messages and standardize error event payload.

The validation logic is sound, but two improvements should be made:

  1. Hardcoded Chinese error messages (lines 40, 51): These should be internationalized or made configurable for broader usability across language-specific deployments.

  2. Inconsistent error event parameters: The maxCount validation (line 41) emits only the error, while the maxSize validation (line 52) emits both the error and the oversized files array. While the type signature (e: 'error', error: Error, files?: File[]): void supports the optional second parameter, the implementation should be consistent—either always pass the problematic files or document why they differ.

packages/components/src/sender/composables/useModeSwitch.ts (1)

83-88: Consider debouncing resize handling more robustly.

The requestAnimationFrame approach doesn't fully throttle rapid resize events—multiple callbacks can still queue. While the isAutoSwitching guard provides protection, consider using useDebounceFn or tracking a pending rAF ID to cancel stale frames.

♻️ Optional: Cancel stale rAF callbacks
+  let rafId: number | null = null
+
   useResizeObserver(containerRef, () => {
-    // 使用 requestAnimationFrame 避免频繁触发
-    requestAnimationFrame(() => {
-      checkOverflow()
-    })
+    if (rafId !== null) {
+      cancelAnimationFrame(rafId)
+    }
+    rafId = requestAnimationFrame(() => {
+      rafId = null
+      checkOverflow()
+    })
   })
packages/components/src/sender/composables/useSenderCore.ts (1)

216-224: Consider using TipTap's onUpdate callback instead of deep watching state.doc.content.

Deep watching editor.value?.state.doc.content with deep: true walks the entire document tree on each change, which is expensive by TipTap design. The intended pattern is to use the editor's onUpdate callback, which receives transaction metadata and allows you to check transaction.docChanged to skip unnecessary work. For a sender component with typically short inputs, the current approach is acceptable, but using onUpdate aligns with TipTap best practices and avoids potential issues if content grows larger.

packages/components/src/sender/extensions/template/select/plugins.ts (2)

137-191: Add edge case handling for null nodes in Backspace logic.

In Scenario 3 (lines 169-182), when afterNode is a templateSelect and beforeNode is a TEMPLATE_BLOCK, the code returns false to delegate handling. However, there's no explicit handling for when beforeNode is null in this scenario, which could occur at the start of a paragraph.

🛡️ Suggested edge case handling
          // 场景3:光标后面是 templateSelect,前面是普通文本
          // 删除文本的最后一个字符
          if (afterNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) {
+           // 如果前面没有节点(段落开头),不处理
+           if (!beforeNode) {
+             return false
+           }
            // 如果前面是普通文本(非零宽字符)
            if (beforeNode?.isText && beforeNode.text !== ZERO_WIDTH_CHAR) {
              dispatch(state.tr.delete($from.pos - 1, $from.pos))
              event.preventDefault()
              return true
            }
            // 如果前面是 template 节点,不处理,让 TemplateBlock 插件处理
            if (beforeNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) {
              return false
            }
          }

193-246: Add edge case handling for null nodes in Delete logic.

Similar to the Backspace scenarios, the Delete key handling in Scenario 3 (lines 224-237) should explicitly handle the case when afterNode is null (at the end of a paragraph).

🛡️ Suggested edge case handling
          // 场景3:光标前面是 templateSelect,后面是普通文本
          // 删除文本的第一个字符
          if (beforeNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_SELECT) {
+           // 如果后面没有节点(段落末尾),不处理
+           if (!afterNode) {
+             return false
+           }
            // 如果后面是普通文本(非零宽字符)
            if (afterNode?.isText && afterNode.text !== ZERO_WIDTH_CHAR) {
              dispatch(state.tr.delete($from.pos, $from.pos + 1))
              event.preventDefault()
              return true
            }
            // 如果后面是 template 节点,不处理,让 TemplateBlock 插件处理
            if (afterNode?.type.name === NODE_TYPE_NAMES.TEMPLATE_BLOCK) {
              return false
            }
          }
packages/components/src/sender/extensions/suggestion/extension.ts (1)

36-51: Consider documenting the reactive behavior.

The onCreate hook sets up a Vue watcher only when items is a ref. This reactive pattern is valuable but might not be immediately obvious to developers extending or using this extension.

📝 Suggested documentation enhancement
  onCreate() {
+   // Setup reactive watching for items when provided as a Vue ref
+   // This allows the suggestion list to update automatically when items change
    if (isRef(this.options.items)) {
      // 保存到实例 storage
      this.storage.watchStopHandle = watch(
        this.options.items,
        () => {
          // 触发更新
          const tr = this.editor.state.tr
          // 使用 SuggestionPluginKey 确保插件能正确接收更新
          tr.setMeta(SuggestionPluginKey, { type: 'update' })
          this.editor.view.dispatch(tr)
        },
        { deep: true },
      )
    }
  },
packages/components/src/sender/extensions/template/select/extension.ts (1)

100-104: Document the TypeScript suppression reason.

The @ts-expect-error comment mentions "Vue SFC type compatibility" but doesn't provide context about why this is necessary or link to relevant issues.

📝 Enhanced documentation
  // 使用 Vue 组件渲染
  addNodeView() {
-   // @ts-expect-error - Vue SFC type compatibility
+   // @ts-expect-error - VueNodeViewRenderer expects a specific component type
+   // but Vue SFC types don't always match perfectly. This is a known limitation
+   // when using TipTap with Vue 3. See: https://github.com/ueberdosis/tiptap/issues/xxxx
    return VueNodeViewRenderer(TemplateSelectView)
  },
packages/components/src/sender/extensions/template/commands.ts (1)

123-157: Consider using editor lifecycle hooks instead of setTimeout.

The focusFirstTemplate command uses setTimeout(..., 0) (line 129) to defer focus until after document updates. While this pattern works, it's fragile and dependent on the event loop timing.

Consider using TipTap's lifecycle hooks for more reliable timing:

♻️ Alternative approach using editor events
focusFirstTemplate:
  () =>
  ({ editor }: { editor: Editor }) => {
    const blocks = getAllTemplates(editor)
    
    // Queue the focus operation after the next editor update
    const cleanup = editor.on('update', () => {
      cleanup() // Remove the listener after first execution
      
      const { state, view } = editor
      const tr = state.tr

      try {
        let targetPos: number

        if (blocks.length === 0) {
          targetPos = state.doc.content.size - 1
        } else {
          const { node, pos } = blocks[0]
          const contentLength = node.textContent?.length || 0
          targetPos = pos + 1 + contentLength
        }

        const selection = TextSelection.create(state.doc, targetPos)
        tr.setSelection(selection)
        view.dispatch(tr)
        view.focus()
      } catch (error) {
        console.error('[focusFirstTemplate] 设置光标失败', error)
      }
    })

    return true
  },
packages/components/src/sender/extensions/template/block/plugins.ts (1)

18-95: Consider refactoring for improved maintainability.

The handleZeroWidthCharLogic function is quite complex (~77 lines) and handles multiple distinct concerns (paragraph boundaries, empty blocks, consecutive blocks). Consider extracting each concern into separate helper functions to improve readability and testability.

♻️ Suggested refactoring approach

Break down into focused helpers:

function handleParagraphBoundaries(node, pos, parent, todoPositions) { /* lines 26-38 */ }
function handleSingleZeroWidth(node, pos, todoPositions) { /* lines 41-43 */ }
function handleEmptyTemplateBlock(node, pos, todoPositions) { /* lines 47-49 */ }
function handleConsecutiveBlocks(node, pos, parent, todoPositions) { /* lines 52-73 */ }

Then call them in sequence from the main function.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d8a7217 and 4221884.

📒 Files selected for processing (25)
  • packages/components/src/sender-actions/upload-button/index.type.ts
  • packages/components/src/sender-actions/upload-button/index.vue
  • packages/components/src/sender/composables/useAutoSize.ts
  • packages/components/src/sender/composables/useKeyboardShortcuts.ts
  • packages/components/src/sender/composables/useModeSwitch.ts
  • packages/components/src/sender/composables/useSenderCore.ts
  • packages/components/src/sender/extensions/constants.ts
  • packages/components/src/sender/extensions/mention/commands.ts
  • packages/components/src/sender/extensions/mention/components/mention-list.vue
  • packages/components/src/sender/extensions/mention/extension.ts
  • packages/components/src/sender/extensions/mention/plugin.ts
  • packages/components/src/sender/extensions/mention/utils.ts
  • packages/components/src/sender/extensions/suggestion/extension.ts
  • packages/components/src/sender/extensions/suggestion/plugin.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/extensions/template/block/plugins.ts
  • packages/components/src/sender/extensions/template/commands.ts
  • packages/components/src/sender/extensions/template/extension.ts
  • packages/components/src/sender/extensions/template/select/extension.ts
  • packages/components/src/sender/extensions/template/select/plugins.ts
  • packages/components/src/sender/extensions/template/utils.ts
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/extensions/utils/keyboard.ts
  • packages/components/src/sender/types/base.ts
  • packages/components/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/components/src/sender/extensions/template/extension.ts
  • packages/components/vite.config.ts
  • packages/components/src/sender/extensions/mention/utils.ts
  • packages/components/src/sender/extensions/mention/components/mention-list.vue
  • packages/components/src/sender/extensions/utils/index.ts
  • packages/components/src/sender/types/base.ts
  • packages/components/src/sender/extensions/template/block/extension.ts
  • packages/components/src/sender/composables/useKeyboardShortcuts.ts
  • packages/components/src/sender/composables/useAutoSize.ts
  • packages/components/src/sender-actions/upload-button/index.type.ts
🧰 Additional context used
🧬 Code graph analysis (4)
packages/components/src/sender/extensions/suggestion/extension.ts (3)
packages/components/src/sender/extensions/suggestion/types.ts (1)
  • SuggestionOptions (135-249)
packages/components/src/sender/extensions/constants.ts (1)
  • EXTENSION_NAMES (7-14)
packages/components/src/sender/extensions/suggestion/plugin.ts (2)
  • SuggestionPluginKey (23-23)
  • createSuggestionPlugin (38-510)
packages/components/src/sender/extensions/template/commands.ts (4)
packages/components/src/sender/extensions/constants.ts (2)
  • NODE_TYPE_NAMES (22-33)
  • USER_API_TYPES (63-72)
packages/components/src/sender/types/base.ts (1)
  • TemplateItem (53-119)
packages/components/src/sender/extensions/template/types.ts (3)
  • TemplateItem (10-10)
  • TemplateAttrs (40-50)
  • TemplateSelectAttrs (15-35)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
  • generateId (17-19)
packages/components/src/sender/extensions/template/block/plugins.ts (3)
packages/components/src/sender/extensions/constants.ts (2)
  • NODE_TYPE_NAMES (22-33)
  • PLUGIN_KEY_NAMES (38-55)
packages/components/src/sender/extensions/template/utils.ts (1)
  • ZERO_WIDTH_CHAR (14-14)
packages/components/src/sender/extensions/utils/index.ts (2)
  • isKey (7-7)
  • isAnyKey (7-7)
packages/components/src/sender/extensions/mention/plugin.ts (8)
packages/components/src/sender/extensions/mention/index.ts (1)
  • MentionPluginKey (13-13)
packages/components/src/sender/extensions/mention/types.ts (2)
  • MentionSuggestionState (109-129)
  • MentionItem (15-39)
packages/components/src/sender/extensions/constants.ts (2)
  • PLUGIN_KEY_NAMES (38-55)
  • NODE_TYPE_NAMES (22-33)
packages/components/src/sender/extensions/suggestion/plugin.ts (1)
  • view (428-508)
packages/components/src/sender/extensions/utils/index.ts (4)
  • findTextRange (6-6)
  • isKey (7-7)
  • isAnyKey (7-7)
  • generateId (5-5)
packages/components/src/sender/extensions/utils/position.ts (1)
  • findTextRange (26-65)
packages/components/src/sender/extensions/utils/keyboard.ts (2)
  • isKey (25-27)
  • isAnyKey (40-42)
packages/components/src/sender/extensions/utils/id-generator.ts (1)
  • generateId (17-19)
🪛 ast-grep (0.40.4)
packages/components/src/sender/extensions/template/utils.ts

[warning] 38-38: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 53-53: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(ZERO_WIDTH_CHAR, 'g')
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🪛 Biome (2.1.2)
packages/components/src/sender/extensions/template/block/plugins.ts

[error] 424-426: This branch can never execute. Its condition is a duplicate or covered by previous conditions in the if-else-if chain.

(lint/suspicious/noDuplicateElseIf)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (40)
packages/components/src/sender-actions/upload-button/index.vue (6)

1-18: LGTM!

The component setup is clean. Props are correctly defined with appropriate defaults, and the use of toRefs for reactive file dialog options follows Vue 3 best practices.


20-23: LGTM!

The disabled state composition correctly combines local and context-level disabled states, allowing both component-level and system-level control.


25-30: LGTM!

The file dialog is correctly configured with reactive options, and the reset: true default ensures a clean state after each file selection.


61-65: LGTM!

The click handler appropriately guards against disabled state before opening the file dialog.


67-70: LGTM!

Exposing the open method provides a clean programmatic API for parent components to trigger file selection.


73-82: LGTM!

The template correctly wires all props and events to the underlying ActionButton, with appropriate fallback for the icon prop.

packages/components/src/sender/composables/useModeSwitch.ts (6)

1-9: LGTM!

Clean module header with appropriate imports from Vue, VueUse, and Tiptap.


11-31: LGTM!

State initialization and container resolution are well-structured. The useTimeoutFn correctly manages the auto-switching debounce with immediate: false.


37-61: LGTM!

The overflow detection logic using scrollWidth > clientWidth is a reliable browser-native approach. The mode switching conditions are well-guarded with appropriate early returns.


67-81: LGTM!

The mode setter correctly guards against redundant updates and properly manages the auto-switching flag with timeout reset. Focusing the editor at 'end' after mode switch is a good UX pattern.


90-98: LGTM!

The watcher correctly synchronizes external mode prop changes with internal state, updating both initialMode and triggering setMode.


100-106: LGTM!

Clean return object matching the expected UseModeSwitchReturn interface.

packages/components/src/sender/composables/useSenderCore.ts (8)

1-29: LGTM!

Well-organized imports with clear separation between external dependencies, internal extensions, and composables.


30-52: LGTM!

Clear interface definition for the composable's return type with well-documented context and expose objects.


70-95: LGTM!

Computed properties are well-structured with appropriate null guards. The canSubmit logic correctly aggregates all submission prerequisites.


101-133: LGTM with a note on mutual exclusivity.

The submit flow correctly handles Template and Mention as mutually exclusive scenarios. The fallback to getText() when no extension produces content is appropriate.

If both extensions were ever loaded simultaneously (contrary to the documented design), Template would take precedence due to the if/else-if structure—this behavior seems intentional.


157-206: LGTM!

The dynamic keyboard handler injection is well-implemented. The plugin state checks ensure popups/dropdowns handle their own keyboard events. The setTimeout(0) for newline in single mode is an acceptable pattern to ensure mode transition completes before inserting the line break.


230-254: LGTM!

Helper methods are concise with appropriate null-safety via optional chaining. The clear() method correctly combines content clearing with focus restoration.


260-288: LGTM!

Context assembly is well-structured with appropriate use of computed() for props needing default values and toRef() for optional props. The provide() call correctly exposes the context for child components.


294-306: LGTM!

Clean return structure providing both the context for dependency injection and the expose object for parent component access.

packages/components/src/sender/extensions/template/select/plugins.ts (3)

13-32: LGTM!

The TemplateSelectDropdownState interface and PluginKey are well-defined and properly typed for managing dropdown state in ProseMirror.


34-73: LGTM!

The dropdown state management plugin correctly implements the ProseMirror plugin pattern with proper state initialization and meta-driven state transitions.


115-252: Verify keyboard deletion logic with comprehensive testing.

The keyboard plugin implements complex deletion scenarios with multiple cursor positions and node type interactions. Given the intricate edge cases involving zero-width characters, template nodes, and paragraph boundaries, thorough testing is essential to ensure correct behavior.

Consider adding integration tests that cover:

  1. Deleting templateSelect nodes at various positions (start/middle/end of paragraph)
  2. Interactions between templateSelect and templateBlock nodes
  3. Zero-width character handling during deletions
  4. Cursor positioning after deletions
  5. Edge cases with empty paragraphs or single-node paragraphs

You can create test scenarios using ProseMirror test-builder utilities to verify the plugin behavior programmatically.

packages/components/src/sender/extensions/suggestion/extension.ts (1)

29-58: LGTM! Proper lifecycle management for Vue reactivity.

The extension correctly implements storage for the watch handle and properly cleans it up in the onDestroy hook. The reactive items ref is watched and triggers plugin updates as expected.

packages/components/src/sender/extensions/utils/keyboard.ts (1)

1-68: LGTM! Clean and well-documented keyboard utilities.

The keyboard utility functions are well-structured with clear documentation and examples. The implementation correctly uses the centralized KEYBOARD_KEYS mapping to provide cross-platform keyboard event handling.

packages/components/src/sender/extensions/template/select/extension.ts (1)

43-59: Good error handling for JSON parsing.

The parseHTML implementation correctly uses a try-catch block to handle potential JSON parsing errors and provides a safe fallback to an empty array.

packages/components/src/sender/extensions/mention/commands.ts (1)

17-28: The value property is intentionally optional in MentionAttrs.

The type definition at line 78 in types.ts shows value?: string, which is documented as "关联值(可选)" (Associated value - optional). The current code correctly allows value to be undefined, which aligns with the type design. No validation is needed.

Likely an incorrect or invalid review comment.

packages/components/src/sender/extensions/mention/extension.ts (2)

20-28: LGTM!

The node configuration is appropriate for mention nodes: inline, atom, and selectable properties ensure mentions are treated as indivisible units that can be selected but not edited internally.


108-126: LGTM! Proper reactive data handling.

The lifecycle hooks correctly handle reactive items with a Vue watcher and properly dispatch transactions to trigger plugin updates. The deep watch and cleanup in onDestroy prevent memory leaks.

packages/components/src/sender/extensions/template/utils.ts (2)

9-24: LGTM!

The utility functions provide a clean API for extracting template content. The zero-width character constant is properly documented with Unicode reference.


38-39: Static analysis false positive - regex pattern is safe.

The static analysis tool flagged lines 38 and 54 for potential ReDoS vulnerabilities when constructing RegExp from a variable. However, this is a false positive because ZERO_WIDTH_CHAR is a constant single Unicode character ('\u200B'), not user input. The pattern is safe and performs a simple global character replacement.

As per static analysis hints, the tool suggested validating regex safety, but in this case the constant nature of the input makes it inherently safe. No remediation needed.

Also applies to: 54-54

packages/components/src/sender/extensions/suggestion/plugin.ts (3)

192-238: LGTM! Well-designed autocomplete UX.

The decorator implementation correctly shows autocomplete hints only when the cursor is at the document end (lines 203-206), preventing confusing mid-text insertions. The conditional TAB indicator provides good user guidance.


428-509: LGTM! Proper component lifecycle management.

The view implementation correctly handles the creation, updating, and destruction of the Vue-rendered popup. Cleanup handlers are properly invoked to prevent memory leaks.


109-120: The use of setContent is intentional and correctly documented.

Line 116 using editor.commands.setContent(item.content) is the intended behavior for this suggestion plugin. The type definition (packages/components/src/sender/extensions/suggestion/types.ts) explicitly documents this with examples showing setContent as the standard method for filling suggestion content. The onSelect callback design allows users to return false to prevent default behavior and customize the fill operation if needed, supporting use cases where content should be modified before insertion (e.g., adding prefixes/suffixes).

Likely an incorrect or invalid review comment.

packages/components/src/sender/extensions/template/commands.ts (1)

36-94: LGTM! Robust template data handling.

The setTemplateData command properly handles all template item types with appropriate ID generation and default values. The conditional nested text node creation for template blocks (lines 62-69) correctly handles empty content.

packages/components/src/sender/extensions/mention/plugin.ts (3)

72-125: LGTM! Clean state management.

The plugin state correctly handles both reactive (Ref) and static item arrays (lines 116-117) and uses the findTextRange utility to detect trigger patterns. The state transitions are well-defined.


145-236: LGTM! Well-implemented keyboard handling.

The backspace handling (lines 149-176) correctly converts mention nodes back to trigger characters with proper position calculations. The fallback logic for Enter/Tab (lines 219-222) ensures functionality even when the component method is unavailable.


324-353: LGTM! Correct mention insertion logic.

The insertMention function properly creates a mention node with a trailing space and positions the cursor after both (line 346: range.from + 2). This calculation is correct for atom nodes, which have a nodeSize of 1 in ProseMirror.

packages/components/src/sender/extensions/template/block/plugins.ts (1)

100-111: LGTM!

The ensureZeroWidthChars plugin correctly uses appendTransaction to maintain zero-width character consistency, and the pasteHandlerPlugin properly handles both HTML and plain text paste scenarios with multi-line support.

Also applies to: 593-653

packages/components/src/sender/extensions/constants.ts (1)

1-98: LGTM!

This constants file provides a clean, centralized source of truth for string identifiers across the extension system. The use of as const assertions ensures type safety, and the comprehensive JSDoc comments clearly document each constant's purpose and usage context.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In @packages/components/src/sender-compat/index.vue:
- Line 23: The computed `defaultValue` currently uses an unsafe type assertion
("const defaultValue = computed(() => props.defaultValue as string") which can
hide non-string values; update the `defaultValue` computed to validate
`props.defaultValue` at runtime (e.g., check typeof props.defaultValue ===
'string') and return the string when valid or a safe fallback (empty string or
props.defaultValue?.toString()) and optionally log or warn when the type is
unexpected; reference the computed `defaultValue` and the prop
`props.defaultValue` when implementing this guard.
- Around line 83-105: The setTemplateData function calls
editor.commands.setTemplateData and editor.commands.focusFirstTemplate without
guarding against runtime exceptions; wrap the calls inside a try-catch around
the block that converts data and invokes editor.commands (in the setTemplateData
function), catch any error thrown, call console.error or the component logger
with a clear message including the caught error, and add a safe fallback (e.g.,
call editor.commands.clearContent() or leave editor unchanged) so an exception
from setTemplateData or focusFirstTemplate does not propagate and break the app.

In @packages/components/src/sender/extensions/template/block/plugins.ts:
- Around line 381-402: The selection-expansion logic around BACKSPACE currently
only inspects $from.nodeBefore/$from.nodeAfter and can miss zero-width chars at
the far end of a non-empty selection; update the block that computes
startPos/endPos (the code using selection, $from, $to, ZERO_WIDTH_CHAR) to also
inspect $to.nodeBefore and $to.nodeAfter and adjust startPos/endPos accordingly
so any zero-width chars adjacent to either boundary are included before calling
state.tr.delete and dispatch.
- Around line 326-378: The else-if branch is unreachable because you test
beforeNode.text === ZERO_WIDTH_CHAR in the outer if and then again in the
else-if; move the index===1 / delete-newline logic into the same beforeNode.text
=== ZERO_WIDTH_CHAR branch (i.e., under the if (beforeNode.text ===
ZERO_WIDTH_CHAR) block) or restructure the outer condition to first check
beforeNode.isText and then switch on index (index > 1 vs index === 1). Update
the block handling ZERO_WIDTH_CHAR (references: beforeNode, ZERO_WIDTH_CHAR,
index, prevPrevNode, $from, selection, dispatch) so the index===1 case executes
when beforeNode.text === ZERO_WIDTH_CHAR instead of being placed in an else that
can never run.
- Around line 551-572: The Delete-key selection handler incorrectly inspects
$from for the end-of-selection zero-width char; update the logic in the DELETE
branch (the isKey(event, 'DELETE') block) to mirror the Backspace fix: derive
and use selection.$to (e.g., $to) to check the end boundary and adjust endPos
when $to.nodeAfter is text and startsWith(ZERO_WIDTH_CHAR), while keeping the
startPos adjustment using $from.nodeBefore; then dispatch the adjusted tr as
before.
- Around line 604-611: The deletion of $from.nodeBefore mutates the document and
invalidates the original $from resolved position, so after performing the first
tr.delete you must map and re-resolve the original position before
checking/removing nodeAfter; do this by mapping the original $from.pos through
tr.mapping (e.g. mappedPos = tr.mapping.map($from.pos)) and then resolving it
against the updated transaction document (e.g. newFrom =
tr.doc.resolve(mappedPos)) and use newFrom.nodeAfter for the second
ZERO_WIDTH_CHAR check and potential tr.delete to avoid referencing stale nodes.
🧹 Nitpick comments (5)
packages/components/src/sender/composables/useEditor.ts (2)

87-90: Consider renaming the callback parameter for clarity.

The parameter name props shadows the component props in scope, which could cause confusion when reading or maintaining this code.

♻️ Rename callback parameter
-    onUpdate: (props) => {
-      const text = props.editor.getText()
+    onUpdate: ({ editor: editorInstance }) => {
+      const text = editorInstance.getText()
       emit('update:modelValue', text)
       emit('input', text)
     },

110-120: The manual transaction dispatch is necessary due to a Tiptap Placeholder extension limitation.

Tiptap's Placeholder extension does not automatically react to changes in Vue refs, even when passed as a function. Using () => placeholderRef.value only evaluates at initialization; subsequent ref changes don't trigger the placeholder to update. The manual empty transaction dispatch (editor.view.dispatch(tr)) forces Tiptap to re-evaluate its decorations, which is a documented workaround for this limitation.

The current implementation is correct. Consider adding an inline comment explaining this Tiptap limitation so future maintainers understand why the dispatch is necessary.

packages/components/src/sender/composables/useSenderCore.ts (1)

218-229: Document or eliminate the overflow check delay.

Line 224 uses setTimeout(..., 0) to defer checkOverflow() after content changes. This appears to wait for DOM updates, but the timing assumption might be fragile.

Consider alternatives:

  1. Use nextTick() for more explicit Vue lifecycle synchronization
  2. If waiting for editor layout, consider using editor.value.view.updateState() or checking editor state stability
  3. Add a comment explaining why the delay is needed
♻️ Alternative using nextTick
   watch(
     () => editor.value?.state.doc.content,
     () => {
-      setTimeout(() => {
+      nextTick(() => {
         checkOverflow()
-      }, 0)
+      })
     },
     { deep: true },
   )

Note: Import nextTick from vue if using this approach.

packages/components/src/sender-compat/index.vue (1)

29-61: Consider adding diagnostic logging when extensions aren't available.

The code accesses Sender.Suggestion and Sender.Template as static properties (properly exported via Object.assign in the Sender module). The type assertions to any are necessary since these runtime-added properties aren't visible to TypeScript, and the existing guards prevent crashes.

However, adding console warnings when expected extensions aren't found would improve observability, consistent with the existing logging pattern in setTemplateData at line 93:

Suggested enhancement
  // 转换 suggestions
  if (props.suggestions?.length) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const Suggestion = (Sender as any).Suggestion
    if (Suggestion) {
      exts.push(
        Suggestion.configure({
          items: props.suggestions,
          popupWidth: props.suggestionPopupWidth,
          activeSuggestionKeys: props.activeSuggestionKeys,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          onSelect: (item: any) => {
            emit('suggestion-select', item.content)
          },
        }),
      )
+    } else {
+      console.warn('[sender-compat] Sender.Suggestion extension not found')
    }
  }

  // 添加 Template 扩展(空配置,通过 setTemplateData 方法手动设置)
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const Template = (Sender as any).Template
  if (Template) {
    exts.push(Template.configure({}))
+  } else {
+    console.warn('[sender-compat] Sender.Template extension not found')
  }
packages/components/src/sender/extensions/template/block/plugins.ts (1)

171-177: Potential node comparison issue.

The comparison state.doc.lastChild !== $from.node() compares Node object references. In ProseMirror, these may be different instances even when representing the same logical node. Consider comparing positions instead, e.g., $from.after() < state.doc.content.size.

♻️ Suggested fix
-            } else if (index === parent.childCount - 1 && state.doc.lastChild !== $from.node()) {
+            } else if (index === parent.childCount - 1 && $from.after() < state.doc.content.size) {
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4221884 and b3e9300.

📒 Files selected for processing (6)
  • packages/components/src/sender-actions/submit-button/index.vue
  • packages/components/src/sender-compat/index.vue
  • packages/components/src/sender/components/layouts/SingleLineLayout.vue
  • packages/components/src/sender/composables/useEditor.ts
  • packages/components/src/sender/composables/useSenderCore.ts
  • packages/components/src/sender/extensions/template/block/plugins.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/components/src/sender-actions/submit-button/index.vue
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-27T03:35:11.008Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 59
File: packages/components/src/suggestion-popover/index.vue:131-133
Timestamp: 2025-05-27T03:35:11.008Z
Learning: In the SuggestionPopover component (packages/components/src/suggestion-popover/index.vue), the click handler can be bound unconditionally because the `show` computed property has a custom setter that prevents state mutations when `props.trigger === 'manual'`. This design centralizes trigger mode logic in the computed property rather than requiring conditional checks in event handlers.

Applied to files:

  • packages/components/src/sender-compat/index.vue
🧬 Code graph analysis (2)
packages/components/src/sender/composables/useSenderCore.ts (17)
packages/components/src/sender/index.ts (1)
  • SenderContext (41-41)
packages/components/src/sender/types/context.ts (2)
  • SenderContext (11-158)
  • SENDER_CONTEXT_KEY (165-165)
packages/components/src/sender/composables/useEditor.ts (1)
  • useEditor (26-131)
packages/components/src/sender/composables/index.ts (3)
  • useEditor (5-5)
  • useModeSwitch (7-7)
  • useAutoSize (6-6)
packages/components/src/sender/extensions/index.ts (7)
  • getTextWithTemplates (23-23)
  • getTemplateStructuredData (23-23)
  • getMentionStructuredData (7-7)
  • getTextWithMentions (7-7)
  • MentionPluginKey (6-6)
  • SuggestionPluginKey (11-11)
  • TemplateSelectDropdownPluginKey (23-23)
packages/components/src/sender/extensions/template/index.ts (3)
  • getTextWithTemplates (16-16)
  • getTemplateStructuredData (16-16)
  • TemplateSelectDropdownPluginKey (17-17)
packages/components/src/sender/types/base.ts (1)
  • StructuredData (199-199)
packages/components/src/sender/extensions/constants.ts (1)
  • EXTENSION_NAMES (7-14)
packages/components/src/sender/extensions/mention/utils.ts (2)
  • getMentionStructuredData (77-111)
  • getTextWithMentions (42-62)
packages/components/src/sender/composables/useModeSwitch.ts (1)
  • useModeSwitch (11-106)
packages/components/src/sender/composables/useKeyboardShortcuts.ts (1)
  • useKeyboardShortcuts (17-78)
packages/components/src/sender/extensions/mention/plugin.ts (2)
  • view (239-317)
  • MentionPluginKey (25-25)
packages/components/src/sender/extensions/suggestion/plugin.ts (2)
  • view (428-508)
  • SuggestionPluginKey (23-23)
packages/components/src/sender/extensions/mention/index.ts (1)
  • MentionPluginKey (13-13)
packages/components/src/sender/extensions/suggestion/index.ts (1)
  • SuggestionPluginKey (13-13)
packages/components/src/sender/extensions/template/select/plugins.ts (1)
  • TemplateSelectDropdownPluginKey (30-32)
packages/components/src/sender/composables/useAutoSize.ts (1)
  • useAutoSize (16-104)
packages/components/src/sender/composables/useEditor.ts (2)
packages/components/src/sender/index.type.ts (2)
  • SenderProps (32-227)
  • SenderEmits (236-311)
packages/components/src/sender/types/composables.ts (1)
  • UseEditorReturn (40-51)
🪛 Biome (2.1.2)
packages/components/src/sender/extensions/template/block/plugins.ts

[error] 416-418: This branch can never execute. Its condition is a duplicate or covered by previous conditions in the if-else-if chain.

(lint/suspicious/noDuplicateElseIf)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (23)
packages/components/src/sender/components/layouts/SingleLineLayout.vue (4)

1-9: LGTM! Clean script setup.

The imports and context usage are well-structured. All destructured properties are utilized in the template.


28-32: LGTM! Good slot design.

Providing the :editor prop to the content slot allows consumers to access the editor instance for custom rendering while maintaining a sensible default with EditorContent.


86-91: LGTM! Appropriate single-line styling.

The ProseMirror styling correctly enforces single-line behavior with nowrap, overflow: hidden, and text-overflow: ellipsis. This ensures content stays on one line with truncation when it exceeds the available width.


36-40: DefaultActionButtons is conditionally rendered and the current design pattern is intentional.

The DefaultActionButtons component has internal conditional logic (v-if="hasContent || loading") in its template, so buttons only appear when there is content or a loading state. While DefaultActionButtons is placed unconditionally in the layout template, it does not render unconditionally. The pattern of always positioning defaults after the custom actions-inline slot is consistent across layouts (SingleLineLayout and Footer) and appears to be intentional—allowing custom actions to coexist with defaults rather than replace them. No action needed unless the design intent changes to require full replacement capability.

packages/components/src/sender/composables/useEditor.ts (1)

1-98: Well-structured editor initialization.

The editor setup is solid:

  • Base extensions (Document, Paragraph, Text, History, Placeholder, CharacterCount) provide good foundation
  • User extensions are properly merged
  • Paste handler correctly converts to plain text and respects single/multiple mode
  • Event wiring (onUpdate, onFocus, onBlur) is appropriate
  • Content initialization with fallback chain is correct
packages/components/src/sender/composables/useSenderCore.ts (4)

1-95: Excellent initialization order and state management.

The core composable is well-structured:

  • Clear initialization order with editor first (line 64)
  • Derived state (hasContent, characterCount, isOverLimit, canSubmit) computed correctly
  • Good use of getTextWithTemplates for consistent text extraction
  • Comments clearly document the initialization phases

101-133: Submit logic correctly handles structured data.

The submit method properly:

  • Checks canSubmit conditions before proceeding
  • Handles mutually exclusive Template and Mention extensions
  • Extracts appropriate structured data for each extension type
  • Falls back to plain text when no extensions are present
  • Emits both textContent and optional structuredData

157-211: Verify timing reliability of mode-switch-then-insert pattern.

Lines 194-197 use setTimeout(..., 0) to defer splitBlock after calling setMode('multiple'). This pattern works but relies on timing assumptions about when the mode switch completes.

Consider verifying whether:

  1. The mode switch is truly asynchronous and requires this delay
  2. A more explicit synchronization (e.g., watching currentMode or using a callback) would be more reliable
  3. This timing works consistently across different browsers and Vue versions

If the delay is necessary due to DOM updates or internal state transitions, please add a comment explaining why.

Additionally, you might want to verify this pattern with a test case that rapidly triggers mode switches to ensure it doesn't race.


235-311: Context assembly and public API are well-designed.

The context object and exposed methods provide a clean, comprehensive API:

  • All computed state properly wrapped
  • Methods correctly delegate to editor commands
  • Context provision via SENDER_CONTEXT_KEY enables clean dependency injection
  • Expose object surfaces the right methods for parent components
packages/components/src/sender-compat/index.vue (8)

1-11: LGTM! Props and imports are well-structured.

The component imports are appropriate, and the prop defaults (mode, placeholder, submitType) provide sensible fallbacks for common use cases.


13-21: LGTM! Two-way binding correctly implemented.

The computed property correctly implements Vue 3's v-model pattern with proper nullish coalescing to ensure a non-null value.


63-73: LGTM! Action mapping is clean.

The defaultActions computed properly maps the submit button configuration and returns undefined when empty, which is a good pattern for optional props.


75-81: LGTM! Voice config handling is appropriate.

The type discrimination between boolean and object speech props provides a clean API that supports both simple enable/disable and advanced configuration.


107-160: LGTM! Event handlers properly forward events.

All event handlers correctly forward their respective events to parent components, and the optional chaining on voiceRef methods provides safe access.


162-186: LGTM! Exposed API is well-designed.

The exposed methods use optional chaining for safe access to child component methods, providing a robust public API surface.


213-286: LGTM! Slot architecture is comprehensive.

The slot passthrough and conditional button injection logic correctly handles both single and multiple modes. The mapping of actions to actions-inline for single mode and footer-right for multiple mode maintains separation of concerns.


229-248: Add safeguards for voiceRef stability when mode changes dynamically.

Both VoiceButton instances (lines 241 and 277) share ref="voiceRef". While only one renders based on mode, the mode prop can change dynamically at runtime (auto-switching in the parent Sender component based on content overflow, or external parent updates). If startSpeech() or stopSpeech() are called while mode is transitioning, the ref may point to an unmounting instance or become null, causing the operation to fail silently.

Recommended fixes:

  • Prevent mode changes while speech is active, or
  • Store speech state and re-apply it to the newly mounted VoiceButton instance after mode transition, or
  • Document that mode must not change during active speech sessions.

Also applies to: 265-284

packages/components/src/sender/extensions/template/block/plugins.ts (6)

1-13: LGTM!

Imports are well-organized with proper type-only imports separated using the type keyword.


18-43: LGTM!

The logic correctly handles zero-width character management around template blocks. The descending sort on line 78-83 ensures positions remain valid during batch modifications.


100-111: LGTM!

The plugin correctly uses appendTransaction to ensure zero-width characters are maintained after document changes, with an appropriate early exit when no changes occurred.


182-216: LGTM!

The template block internal navigation correctly handles both empty blocks and content-filled blocks with proper boundary detection.


218-265: LGTM!

The Backspace handling within template blocks covers all edge cases: preserving blocks when deleting the last character, handling zero-width content, empty content, and cursor positioning at boundaries.


614-630: Consider edge case for multi-line paste location.

The multi-line paste creates new paragraph nodes and inserts them at the current position. If the cursor is inside an existing paragraph, this inserts paragraphs inline rather than after the current paragraph, which may produce unexpected nesting or structure. Verify this behavior matches the intended UX.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In @packages/components/src/sender/extensions/suggestion/plugin.ts:
- Around line 447-456: The selection logic in onSelect uses
state.filteredSuggestions.find(item => item.content === content) which can pick
the wrong item when multiple suggestions share the same content; change the
selection to use a unique identifier or index instead — update the
SuggestionList to emit an index or an id and then in the onSelect handler lookup
selectedItem via state.filteredSuggestions[index] or
state.filteredSuggestions.find(item => item.id === emittedId), then call
insertSuggestion(view, state.range, selectedItem) and dispatch the
SuggestionPluginKey close meta as before (ensure symbols: onSelect,
state.filteredSuggestions, insertSuggestion, SuggestionPluginKey, view,
state.range are used to locate and fix the code).

In @packages/components/src/sender/extensions/suggestion/suggestion-list.vue:
- Around line 85-87: handleSelect currently calls props.onSelect with
item.content which can ambiguously match duplicate suggestions; change
handleSelect to pass the suggestion index (e.g., idx) instead of content and
update the props.onSelect invocation/typing to accept an index (number) so
selection is unambiguous; also adjust the consumer logic (the plugin's
find-by-content path) to use the received index to directly select the correct
suggestion item rather than searching by content.

In @packages/components/src/sender/extensions/suggestion/types.ts:
- Around line 135-148: The JSDoc says items is required but the type declares it
optional; make the type match the documentation by removing the optional marker
so SuggestionOptions.items is declared as items: SenderSuggestionItem[] |
Ref<SenderSuggestionItem[]>; update any callers if necessary to always provide
items and keep the existing JSDoc comment unchanged.

In @packages/components/src/sender/index.type.ts:
- Around line 25-30: The exported interface DefaultConfig is currently unused
but part of the public API; either document it for consumers or remove it if
unintended. If keeping it, add a JSDoc block above the DefaultConfig declaration
describing its purpose, each property (placeholder, mode, submitType, autoSize
with minRows/maxRows), expected types/values and example usage; if it's not
meant to be public, delete the exported DefaultConfig or change it to a
non-exported/internal type and update any related exports to avoid exposing it.
🧹 Nitpick comments (5)
packages/components/src/sender/index.ts (1)

14-19: Component name may cause inconsistency with main install function.

Based on the relevant code snippet from packages/components/src/index.ts, the main install function strips Tiny or Tr prefixes before re-registering components. Setting name = 'TrSender' here means:

  • This local install registers as TrSender
  • The main install would register it as TrSender (after stripping TrSender, then adding Tr back)

This works correctly, but consider whether Sender (without prefix) would be more consistent with other components in the library, letting the main install handle the Tr prefix uniformly.

packages/components/src/sender/index.type.ts (2)

167-168: Consider improving type safety for extensions array.

The any[] fallback undermines type safety. If the goal is to support custom extensions that may not strictly match Extension, consider using a more specific union type or documenting why any[] is necessary.

💡 Potential improvement
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  extensions?: Extension[] | any[]
+  /**
+   * Using Extension<any, any> to support extensions with various configurations
+   * while maintaining some type safety
+   */
+  extensions?: Extension<any, any>[]

Alternatively, if third-party extensions truly require any, keep the current approach but ensure the eslint-disable is documented with a reason.


327-370: LGTM! Comprehensive slot definitions with proper typing.

The slot interfaces are well-documented with examples. The use of SenderSlotScope for action slots provides consistent access to insert, focus, and disabled properties.

One minor note: consider whether content slot's editor: unknown could use a more specific type from Tiptap (e.g., Editor from @tiptap/core) to improve developer experience.

💡 Optional: Use specific Editor type
+import type { Editor } from '@tiptap/core'
+
 export interface SenderSlots {
   // ...
-  content?: (props: { editor: unknown }) => unknown
+  content?: (props: { editor: Editor | null }) => unknown
   // ...
 }
packages/components/src/sender/extensions/suggestion/types.ts (1)

130-130: Move import to the top of the file.

The import type { Ref } from 'vue' statement is placed mid-file. Convention is to group imports at the top for better readability.

♻️ Suggested fix
+import type { Ref } from 'vue'
+
 /**
  * Suggestion 插件类型定义
  *

And remove line 130.

packages/components/src/sender/extensions/suggestion/plugin.ts (1)

86-88: Consider removing this pass-through function or adding a TODO.

getCurrentQuery simply returns its input. If it's a placeholder for future character-trigger mode, a brief comment would clarify intent. Otherwise, it could be inlined.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c9f965d and fbb7a78.

📒 Files selected for processing (9)
  • packages/components/src/sender/extensions/index.ts
  • packages/components/src/sender/extensions/suggestion/index.ts
  • packages/components/src/sender/extensions/suggestion/plugin.ts
  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
  • packages/components/src/sender/extensions/suggestion/types.ts
  • packages/components/src/sender/extensions/suggestion/utils/highlight.ts
  • packages/components/src/sender/index.ts
  • packages/components/src/sender/index.type.ts
  • packages/components/src/sender/types/components.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/components/src/sender/extensions/suggestion/index.ts
  • packages/components/src/sender/types/components.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-27T03:35:11.008Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 59
File: packages/components/src/suggestion-popover/index.vue:131-133
Timestamp: 2025-05-27T03:35:11.008Z
Learning: In the SuggestionPopover component (packages/components/src/suggestion-popover/index.vue), the click handler can be bound unconditionally because the `show` computed property has a custom setter that prevents state mutations when `props.trigger === 'manual'`. This design centralizes trigger mode logic in the computed property rather than requiring conditional checks in event handlers.

Applied to files:

  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
🧬 Code graph analysis (3)
packages/components/src/sender/extensions/suggestion/plugin.ts (8)
packages/components/src/sender/extensions/index.ts (5)
  • SuggestionPluginKey (11-11)
  • SuggestionState (16-16)
  • SuggestionOptions (15-15)
  • SenderSuggestionItem (14-14)
  • syncAutoComplete (12-12)
packages/components/src/sender/extensions/suggestion/index.ts (2)
  • SuggestionPluginKey (13-13)
  • syncAutoComplete (15-15)
packages/components/src/sender/extensions/suggestion/types.ts (3)
  • SuggestionState (83-128)
  • SuggestionOptions (135-249)
  • SenderSuggestionItem (48-76)
packages/components/src/sender/index.ts (3)
  • SuggestionState (59-59)
  • SuggestionOptions (58-58)
  • SenderSuggestionItem (57-57)
packages/components/src/sender/index.type.ts (3)
  • SuggestionState (19-19)
  • SuggestionOptions (18-18)
  • SenderSuggestionItem (17-17)
packages/components/src/sender/extensions/constants.ts (1)
  • PLUGIN_KEY_NAMES (38-55)
packages/components/src/sender/extensions/suggestion/utils/filter.ts (1)
  • syncAutoComplete (25-57)
packages/components/src/sender/extensions/utils/keyboard.ts (2)
  • isKey (25-27)
  • isAnyKey (40-42)
packages/components/src/sender/index.ts (1)
packages/components/src/index.ts (3)
  • install (101-106)
  • Sender (136-136)
  • Sender (137-137)
packages/components/src/sender/extensions/suggestion/utils/highlight.ts (4)
packages/components/src/sender/extensions/suggestion/types.ts (2)
  • SenderSuggestionItem (48-76)
  • SuggestionTextPart (10-13)
packages/components/src/sender/index.ts (2)
  • SenderSuggestionItem (57-57)
  • SuggestionTextPart (60-60)
packages/components/src/sender/index.type.ts (2)
  • SenderSuggestionItem (17-17)
  • SuggestionTextPart (20-20)
packages/components/src/sender-compat/index.type.ts (1)
  • SuggestionTextPart (55-58)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (27)
packages/components/src/sender/extensions/index.ts (1)

1-24: Well-organized barrel file with clear extension groupings.

The export structure is clean and consistent across all three extension modules. Each section appropriately separates the main extension, plugin key, utility functions, and type exports. All exported symbols are correctly defined in their respective source modules.

packages/components/src/sender/index.ts (4)

22-33: LGTM! Clean extension pattern.

The Object.assign approach to attach static properties (extension classes and convenience functions) is a well-established pattern for Vue components. This provides a clean API: Sender.Mention.configure() for inheritance and Sender.mention() for simple scenarios.


53-62: LGTM! Extension type exports are well-organized.

The extension types are properly grouped and exported, providing consumers with access to TemplateAttrs, TemplateOptions, MentionAttrs, MentionOptions, and Suggestion-related types for advanced typing needs.


51-51: useSenderContext is properly exported from ./context.

The export is confirmed at packages/components/src/sender/context/index.ts:12. The re-export in the index file is valid.


37-49: All exported types are properly defined and exported from ./index.type.

Verification confirms that all 11 types (SenderProps, SenderEmits, SenderSlots, SenderContext, UseEditorReturn, UseModeSwitchReturn, UseSuggestionReturn, UseKeyboardShortcutsReturn, TemplateItem, MentionItem, DefaultActions) are correctly exported. The file uses a combination of wildcard re-exports (export * from './types/base', export * from './types/composables', export * from './types/context', export * from './types/slots') and explicit exports (export type { MentionItem } from './extensions/mention').

packages/components/src/sender/index.type.ts (3)

1-9: LGTM! Clean module organization.

The barrel re-exports from sub-modules (types/base, types/composables, etc.) provide a clean public API surface while keeping implementation details modular.


243-276: Well-designed submit event signature with structured data.

The enhanced submit event that provides both plain text and optional StructuredData is a clean API design. It allows simple use cases to just use the text while advanced scenarios can access template/mention metadata.


39-234: Comprehensive and well-documented props interface.

The SenderProps interface is thorough with:

  • Clear grouping of props by purpose (core data, config, mode, content, extensions, styling, actions, submit)
  • Detailed JSDoc with defaults and usage examples
  • Good use of imported base types (InputMode, SubmitTrigger, AutoSize, etc.)

The documentation quality is excellent and will help consumers understand the API.

packages/components/src/sender/extensions/suggestion/utils/highlight.ts (3)

29-111: LGTM!

The interval merging algorithm is well-implemented, correctly handling overlapping matches and edge cases (empty highlights, no matches). The case-insensitive matching while preserving original text length is a nice touch.


129-135: LGTM!

Clean delegation to the core function with proper empty input handling.


173-188: LGTM!

The three-mode dispatch logic is clean and correctly matches the SenderSuggestionItem.highlights type definition.

packages/components/src/sender/extensions/suggestion/types.ts (4)

10-22: LGTM!

Type definitions are clean and consistent with the compat layer's SuggestionTextPart interface.


48-76: LGTM!

The SenderSuggestionItem interface is well-designed with clear documentation. The flexible highlights union type elegantly supports all three highlighting modes.


83-128: LGTM!

Comprehensive state interface with clear documentation for both global and character trigger modes.


260-269: or after

packages/components/src/sender/extensions/suggestion/plugin.ts (8)

20-30: LGTM!

Plugin key and options interface are correctly defined, extending the public SuggestionOptions with the internal editor dependency.


56-70: LGTM!

The Ref detection via duck-typing ('value' in items) avoids importing Vue's isRef while still handling reactive refs. The fallback to suggestions provides backwards compatibility.


109-120: LGTM!

The onSelect callback pattern with !== false check allows consumers to prevent default insertion while still receiving the selection event.


140-187: LGTM!

Good use of @floating-ui/dom with proper cleanup of autoUpdate subscription. The percentage-based width calculation is a nice feature for responsive layouts.


192-238: LGTM!

Smart UX decision to only show autocomplete when cursor is at document end, preventing confusing mid-text insertions. The decoration widget is properly marked as non-editable.


262-276: LGTM!

The justClosed flag with setTimeout(0) is a clever pattern to prevent the suggestion list from immediately reopening after being closed, handling the race condition between transaction dispatch and state updates.


366-425: LGTM!

Comprehensive keyboard handling with circular navigation, configurable selection keys, and proper event prevention. The separation between Tab (autocomplete) and Enter (selection via activeSuggestionKeys) provides good flexibility.


502-507: LGTM!

Thorough cleanup in destroy() properly releases all resources: floating-ui subscription, Vue component, and DOM element.

packages/components/src/sender/extensions/suggestion/suggestion-list.vue (4)

10-55: LGTM!

Using callback props instead of emits is the right choice here since the component is instantiated via VueRenderer rather than in a template. The optional callbacks with ?.() invocation are safe.


92-102: LGTM!

The scrollIntoView({ block: 'nearest' }) is the correct choice for keyboard navigation in lists, ensuring visibility without jarring scroll jumps.


113-134: LGTM!

Template logic is clean. Using @click for selection and @mouseenter/@mouseleave for hover state is appropriate. The inline processHighlights call in v-for is acceptable given typical suggestion list sizes.


139-216: LGTM!

Well-structured styles using CSS custom properties for theming, BEM-like naming, and proper scrollbar customization for both WebKit and Firefox (scrollbar-color). The slide-up transition provides smooth UX.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @packages/components/src/sender/extensions/suggestion/plugin.ts:
- Around line 48-51: The module-scoped variables component, popup, cleanup, and
justClosed must be moved into the plugin instance to avoid shared mutable state:
relocate their declarations inside createSuggestionPlugin (or inside the object
returned by its view() method) so each invocation gets its own
component/popup/cleanup/justClosed, update all references in
createSuggestionPlugin and the view() implementation to use the instance-scoped
variables, and ensure the setTimeout callback is tracked via the instance
cleanup and cleared in the view()'s destroy() to avoid memory leaks and
cross-instance interference.
- Around line 109-120: The insertSuggestion function accepts a redundant range
parameter that is only null-checked and never used; remove the range parameter
from insertSuggestion and from all internal calls, update any callers that pass
a range to stop providing it (or adapt them to the new signature), and keep the
existing onSelect callback and editor.commands.setContent flow unchanged so
default whole-document replacement still occurs; if partial insertion is needed
later, handle it via onSelect instead of adding range back.
- Around line 486-503: The update(view: EditorView) method in the suggestion
plugin should be wrapped in a try-finally so the cleanup block that destroys
component, nulls cleanup, and removes popup always runs on errors; modify the
update function (the one using SuggestionPluginKey.getState and
creating/positioning component/popup) to enclose its current body in try { ... }
finally { /* run the existing else cleanup: cleanup?.(); cleanup = null;
component?.destroy(); component = null; popup?.remove(); popup = null; */ } and
keep destroy() as-is for fallback.
🧹 Nitpick comments (4)
packages/components/src/sender/extensions/suggestion/plugin.ts (3)

256-276: Refactor fragile timing hack with setTimeout.

Lines 263-266 use justClosed with setTimeout(..., 0) to prevent the suggestion list from immediately reopening after closing. This timing-based approach is fragile and could fail under different execution conditions.

♻️ Consider using transaction metadata instead

A more robust approach is to check the transaction that triggered the state change:

-        if (meta.type === 'close') {
-          justClosed = true
-          setTimeout(() => {
-            justClosed = false
-          }, 0)
           return {
             active: false,
             range: null,
             query: '',
             filteredSuggestions: [],
             selectedIndex: -1,
             autoCompleteText: '',
             showTabIndicator: false,
           }
         }
       }

-      // 保持关闭状态,防止立即重新打开
-      if (justClosed) {
-        return state
-      }
-
       // 如果文档没有变化,保持状态
-      if (!tr.docChanged && !tr.selectionSet) {
+      // Skip reopening if this is the same transaction that closed it or if nothing changed
+      if ((!tr.docChanged && !tr.selectionSet) || (meta && meta.type === 'close')) {
         return state
       }

This eliminates the timing dependency and the need for setTimeout.


145-146: Suggest using a configurable selector or data attribute.

Line 145 hardcodes the CSS class .tr-sender for finding the editor wrapper. This creates a tight coupling between the plugin and the component structure.

♻️ Consider making the reference element configurable

Pass the reference element through options or use a data attribute:

 interface PluginOptions extends SuggestionOptions {
   editor: Editor
+  referenceElement?: HTMLElement
 }
   function positionPopup(view: EditorView, popup: HTMLElement) {
     cleanup?.()
 
-    const editorWrapper = view.dom.closest('.tr-sender')
-    const referenceElement = (editorWrapper as HTMLElement) || view.dom
+    const referenceElement = options.referenceElement || 
+      (view.dom.closest('[data-sender-wrapper]') as HTMLElement) || 
+      view.dom

This makes the plugin more reusable and testable.


63-67: Document the Ref handling workaround.

Lines 64-67 handle Vue's Ref type with a type cast and eslint-disable comment. While this works, the comment "避免引入 vue 依赖导致类型问题" (avoid introducing vue dependency causing type issues) could be more specific.

Consider adding a more detailed comment explaining why this is necessary and what the type expectations are:

-    // 处理 Ref (简单的 value 检查,避免引入 vue 依赖导致类型问题)
+    // Handle Vue Ref without importing 'vue' dependency to avoid type conflicts
+    // The extension options may be wrapped in a Ref for reactivity
+    // Check for .value property to unwrap the Ref
     if (items && typeof items === 'object' && 'value' in items) {
packages/components/src/sender/index.type.ts (1)

132-160: Consider more specific typing for extensions property.

Lines 159-160 use Extension[] | any[] with an eslint-disable comment. While this provides flexibility for users who may not have @tiptap/core types available, it bypasses type checking.

Consider using a generic constraint or conditional type to preserve some type safety:

+/**
+ * Tiptap extension or extension-like object
+ */
+export type TiptapExtensionLike = Extension | { name: string; configure?: (options: any) => any }
+
 export interface SenderProps {
   // ...
   
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  extensions?: Extension[] | any[]
+  extensions?: TiptapExtensionLike[]

This provides better IntelliSense while still allowing non-Extension objects that follow the same pattern.

📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fbb7a78 and a38859f.

📒 Files selected for processing (4)
  • packages/components/src/sender/extensions/suggestion/plugin.ts
  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
  • packages/components/src/sender/extensions/suggestion/types.ts
  • packages/components/src/sender/index.type.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/components/src/sender/extensions/suggestion/types.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-27T03:35:11.008Z
Learnt from: gene9831
Repo: opentiny/tiny-robot PR: 59
File: packages/components/src/suggestion-popover/index.vue:131-133
Timestamp: 2025-05-27T03:35:11.008Z
Learning: In the SuggestionPopover component (packages/components/src/suggestion-popover/index.vue), the click handler can be bound unconditionally because the `show` computed property has a custom setter that prevents state mutations when `props.trigger === 'manual'`. This design centralizes trigger mode logic in the computed property rather than requiring conditional checks in event handlers.

Applied to files:

  • packages/components/src/sender/extensions/suggestion/suggestion-list.vue
🧬 Code graph analysis (1)
packages/components/src/sender/extensions/suggestion/plugin.ts (7)
packages/components/src/sender/extensions/suggestion/index.ts (2)
  • SuggestionPluginKey (13-13)
  • syncAutoComplete (15-15)
packages/components/src/sender/extensions/suggestion/types.ts (3)
  • SuggestionState (83-128)
  • SuggestionOptions (135-253)
  • SenderSuggestionItem (48-76)
packages/components/src/sender/index.type.ts (3)
  • SuggestionState (19-19)
  • SuggestionOptions (18-18)
  • SenderSuggestionItem (17-17)
packages/components/src/sender/index.ts (3)
  • SuggestionState (59-59)
  • SuggestionOptions (58-58)
  • SenderSuggestionItem (57-57)
packages/components/src/sender/extensions/constants.ts (2)
  • PLUGIN_KEY_NAMES (38-55)
  • EXTENSION_NAMES (7-14)
packages/components/src/sender/extensions/suggestion/utils/filter.ts (1)
  • syncAutoComplete (25-57)
packages/components/src/sender/extensions/utils/keyboard.ts (2)
  • isKey (25-27)
  • isAnyKey (40-42)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build / build
🔇 Additional comments (8)
packages/components/src/sender/extensions/suggestion/plugin.ts (1)

199-206: Good defensive coding for autocomplete placement.

The guard at lines 202-205 ensures autocomplete text only appears when the cursor is at the document end. This prevents the confusing UX of autocomplete text appearing in the middle of existing content when the user moves the cursor.

packages/components/src/sender/extensions/suggestion/suggestion-list.vue (4)

41-55: Good API design: Using callback props for better composition.

The component uses optional callback props (onSelect, onMouseEnter, onMouseLeave) instead of Vue emits. This is a valid pattern that provides better TypeScript inference and allows the parent (plugin.ts) to handle events without Vue's event system.

Based on learnings, this pattern is intentional and appropriate for this use case.


85-90: Correct usage of toRaw for external callbacks.

Line 88 uses toRaw(item) before passing the item to the callback. This is correct because the item comes from reactive props, and external consumers (the plugin) shouldn't receive Vue reactive proxies.


95-105: Auto-scroll implementation ensures selected item visibility.

The watch on activeKeyboardIndex automatically scrolls the selected item into view using scrollIntoView({ block: 'nearest' }). This provides good UX for keyboard navigation.


142-208: No action needed. All CSS custom properties referenced in the styles (--tr-suggestion-bg-color, --tr-suggestion-box-shadow-color, --tr-suggestion-text-color, etc.) are properly defined in packages/components/src/styles/variables.css for both light and dark themes.

packages/components/src/sender/index.type.ts (3)

1-23: Well-organized type exports with clear structure.

The file properly separates concerns by:

  1. Importing core types from @tiptap/core
  2. Re-exporting internal type modules
  3. Exporting extension-specific types

This makes the public API clear and maintainable.


138-157: Clear and helpful JSDoc examples.

The JSDoc comments for the extensions property include practical examples showing both basic usage and reactive configuration. This significantly improves developer experience.


244-268: Well-documented submit event with structured data.

The submit event documentation (lines 244-268) clearly explains the dual parameters (textContent and structuredData) with a helpful example showing how to handle both template and mention scenarios. This is excellent documentation for a complex API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tr-sender组件添加 @的菜单

2 participants