diff --git a/.changeset/session-emoji-icon.md b/.changeset/session-emoji-icon.md new file mode 100644 index 000000000..3cc402e7e --- /dev/null +++ b/.changeset/session-emoji-icon.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +支持为会话设置 Emoji 图标(选择器可搜索、显示最近使用),在会话选项菜单选择「设置 Emoji…」或点击标题前的 Emoji 即可使用。 diff --git a/apps/desktop/src/renderer/components/SessionEmojiPicker.vue b/apps/desktop/src/renderer/components/SessionEmojiPicker.vue new file mode 100644 index 000000000..7eeb222e8 --- /dev/null +++ b/apps/desktop/src/renderer/components/SessionEmojiPicker.vue @@ -0,0 +1,241 @@ + + + + + + + diff --git a/apps/desktop/src/renderer/components/SessionRow.vue b/apps/desktop/src/renderer/components/SessionRow.vue index 7d61d9f6f..f1de4bcfb 100644 --- a/apps/desktop/src/renderer/components/SessionRow.vue +++ b/apps/desktop/src/renderer/components/SessionRow.vue @@ -1,12 +1,15 @@ - + + + + + + diff --git a/apps/web/src/components/SessionRow.vue b/apps/web/src/components/SessionRow.vue index 7d61d9f6f..f1de4bcfb 100644 --- a/apps/web/src/components/SessionRow.vue +++ b/apps/web/src/components/SessionRow.vue @@ -1,12 +1,15 @@ - + + + + diff --git a/packages/web-core/package.json b/packages/web-core/package.json index 852c8a99d..d78d30e57 100644 --- a/packages/web-core/package.json +++ b/packages/web-core/package.json @@ -6,7 +6,8 @@ "exports": { ".": "./src/index.ts", "./api": "./src/api/index.ts", - "./contracts": "./src/contracts.ts" + "./contracts": "./src/contracts.ts", + "./lib": "./src/lib/index.ts" }, "peerDependencies": { "vue": "^3.5.35" diff --git a/packages/web-core/src/lib/emojiData.ts b/packages/web-core/src/lib/emojiData.ts new file mode 100644 index 000000000..18e5522ce --- /dev/null +++ b/packages/web-core/src/lib/emojiData.ts @@ -0,0 +1,478 @@ +// web-core lib/emojiData — the picker's searchable emoji dataset (curated, not +// the full Unicode set) + the recently-used helper. Every entry must round-trip +// through splitSessionEmoji's detection — a test asserts exactly that, since +// anything the picker writes into a title must parse back as the session icon. + +export type EmojiGroup = 'faces' | 'nature' | 'food' | 'activity' | 'objects' | 'symbols'; + +/** Display order of the groups in the picker's rest view. */ +export const EMOJI_GROUPS: readonly EmojiGroup[] = [ + 'faces', + 'nature', + 'food', + 'activity', + 'objects', + 'symbols', +]; + +/** [emoji, group, keywords] — keywords are lowercase, space-separated, en + zh. */ +const DATA: Array<[string, EmojiGroup, string]> = [ + // Faces & people + ['😀', 'faces', 'grinning smile happy 笑 开心'], + ['😄', 'faces', 'smile happy joy 笑 开心 高兴'], + ['😁', 'faces', 'grin beaming 咧嘴笑 开心'], + ['😂', 'faces', 'joy laugh tears 笑哭 爆笑'], + ['🤣', 'faces', 'rofl laugh rolling 笑翻 爆笑'], + ['😊', 'faces', 'blush shy happy 微笑 害羞'], + ['😉', 'faces', 'wink 眨眼'], + ['😍', 'faces', 'heart eyes love 爱心眼 喜欢 爱'], + ['🥰', 'faces', 'smiling hearts love 爱心 喜欢'], + ['😘', 'faces', 'kiss 飞吻 亲亲'], + ['😋', 'faces', 'yum tongue 好吃 馋'], + ['🤪', 'faces', 'zany crazy 鬼脸 疯'], + ['🤔', 'faces', 'thinking hmm consider 思考 想'], + ['🤨', 'faces', 'skeptical eyebrow 怀疑 挑眉'], + ['😐', 'faces', 'neutral meh 面无表情 无语'], + ['😑', 'faces', 'expressionless 面无表情 无语'], + ['🙄', 'faces', 'eye roll 翻白眼 无语'], + ['😶', 'faces', 'no mouth silent 无言 沉默'], + ['🫡', 'faces', 'salute 敬礼 收到'], + ['🤫', 'faces', 'shush quiet 嘘 安静'], + ['🤭', 'faces', 'oops giggle 捂嘴 偷笑'], + ['😴', 'faces', 'sleeping sleepy 睡觉 困'], + ['😪', 'faces', 'sleepy tired 困 疲惫'], + ['😷', 'faces', 'mask sick 口罩 生病'], + ['🤒', 'faces', 'sick fever 生病 发烧'], + ['🤕', 'faces', 'hurt bandage 受伤'], + ['🤢', 'faces', 'nauseated 恶心'], + ['🤯', 'faces', 'mind blown explode 震惊 爆炸'], + ['🥳', 'faces', 'party celebrate 庆祝 派对'], + ['🤩', 'faces', 'star struck 星星眼 激动'], + ['😎', 'faces', 'cool sunglasses 酷 墨镜'], + ['🥸', 'faces', 'disguise 伪装 假扮'], + ['🤓', 'faces', 'nerd geek 书呆子 学霸'], + ['😢', 'faces', 'cry sad 哭 难过'], + ['😭', 'faces', 'sob cry loudly 大哭 痛哭'], + ['😤', 'faces', 'triumph huff 哼 生气'], + ['😡', 'faces', 'angry rage mad 生气 愤怒'], + ['🤬', 'faces', 'swearing cursing 骂人 爆粗'], + ['😱', 'faces', 'scream fear 尖叫 害怕'], + ['😨', 'faces', 'fearful 害怕 恐惧'], + ['🥵', 'faces', 'hot heat 热 出汗'], + ['🥶', 'faces', 'cold freezing 冷 冻'], + ['🥴', 'faces', 'woozy drunk 晕 醉'], + ['😇', 'faces', 'angel innocent 天使 无辜'], + ['🙃', 'faces', 'upside down silly 倒脸 哭笑不得'], + ['💀', 'faces', 'skull dead 骷髅 笑死'], + ['👻', 'faces', 'ghost 鬼 幽灵'], + ['👍', 'faces', 'thumbs up like good 赞 好'], + ['👎', 'faces', 'thumbs down dislike 踩 差'], + ['👏', 'faces', 'clap applause 鼓掌 厉害'], + ['🙌', 'faces', 'raise hands celebrate 举手 庆祝'], + ['🙏', 'faces', 'pray thanks please 拜托 感谢 祈祷'], + ['💪', 'faces', 'muscle strong flex 加油 强壮 肌肉'], + ['👀', 'faces', 'eyes look watch 看 围观 眼睛'], + ['🤝', 'faces', 'handshake deal 握手 合作'], + ['✌️', 'faces', 'victory peace 胜利 耶'], + ['👋', 'faces', 'wave hello bye 挥手 你好 再见'], + ['🤞', 'faces', 'crossed fingers luck 祈祷 好运'], + ['👌', 'faces', 'ok okay 好的 可以'], + ['🫶', 'faces', 'heart hands love 比心 爱心'], + ['✍️', 'faces', 'writing hand 写字 记录'], + ['🧠', 'faces', 'brain smart 大脑 聪明'], + ['🦾', 'faces', 'mechanical arm 机械臂 力量'], + // Animals & nature + ['🐶', 'nature', 'dog puppy 狗 小狗'], + ['🐱', 'nature', 'cat kitten 猫 小猫'], + ['🐭', 'nature', 'mouse rat 老鼠'], + ['🐹', 'nature', 'hamster 仓鼠'], + ['🐰', 'nature', 'rabbit bunny 兔子'], + ['🦊', 'nature', 'fox 狐狸'], + ['🐻', 'nature', 'bear 熊'], + ['🐼', 'nature', 'panda 熊猫'], + ['🐨', 'nature', 'koala 考拉'], + ['🐯', 'nature', 'tiger 老虎'], + ['🦁', 'nature', 'lion 狮子'], + ['🐮', 'nature', 'cow 牛'], + ['🐷', 'nature', 'pig 猪'], + ['🐸', 'nature', 'frog 青蛙'], + ['🐵', 'nature', 'monkey 猴子'], + ['🐔', 'nature', 'chicken 鸡'], + ['🐧', 'nature', 'penguin 企鹅'], + ['🐦', 'nature', 'bird 鸟'], + ['🐣', 'nature', 'chick hatching 小鸡 孵化'], + ['🦆', 'nature', 'duck 鸭子'], + ['🦉', 'nature', 'owl 猫头鹰'], + ['🐝', 'nature', 'bee 蜜蜂'], + ['🐛', 'nature', 'bug caterpillar 虫子 毛虫'], + ['🦋', 'nature', 'butterfly 蝴蝶'], + ['🐌', 'nature', 'snail slow 蜗牛 慢'], + ['🐢', 'nature', 'turtle slow 乌龟 慢'], + ['🐍', 'nature', 'snake 蛇'], + ['🐙', 'nature', 'octopus 章鱼'], + ['🦑', 'nature', 'squid 鱿鱼'], + ['🦐', 'nature', 'shrimp 虾'], + ['🦀', 'nature', 'crab 螃蟹'], + ['🐠', 'nature', 'tropical fish 鱼 热带鱼'], + ['🐳', 'nature', 'whale 鲸鱼'], + ['🦈', 'nature', 'shark 鲨鱼'], + ['🐊', 'nature', 'crocodile 鳄鱼'], + ['🦄', 'nature', 'unicorn 独角兽'], + ['🐴', 'nature', 'horse 马'], + ['🐑', 'nature', 'sheep 羊 绵羊'], + ['🐐', 'nature', 'goat 山羊'], + ['🦜', 'nature', 'parrot 鹦鹉'], + ['🌸', 'nature', 'blossom flower sakura 樱花 花'], + ['🌹', 'nature', 'rose flower 玫瑰 花'], + ['🌻', 'nature', 'sunflower 向日葵'], + ['🌷', 'nature', 'tulip 郁金香'], + ['🌱', 'nature', 'seedling sprout 发芽 幼苗'], + ['🌲', 'nature', 'tree evergreen 树 松树'], + ['🌳', 'nature', 'deciduous tree 树 大树'], + ['🌵', 'nature', 'cactus 仙人掌'], + ['🍀', 'nature', 'clover luck 四叶草 幸运'], + ['🍁', 'nature', 'maple leaf autumn 枫叶 秋天'], + ['🍄', 'nature', 'mushroom 蘑菇'], + ['🌈', 'nature', 'rainbow 彩虹'], + ['☀️', 'nature', 'sun sunny 太阳 晴'], + ['🌙', 'nature', 'moon crescent 月亮'], + ['⭐', 'nature', 'star 星星'], + ['🌟', 'nature', 'glowing star 星星 闪亮'], + ['☁️', 'nature', 'cloud 云'], + ['⛅', 'nature', 'partly cloudy 多云'], + ['🌧️', 'nature', 'rain rainy 下雨'], + ['❄️', 'nature', 'snowflake snow 雪 雪花'], + ['⛄', 'nature', 'snowman 雪人'], + ['⚡', 'nature', 'lightning bolt 闪电'], + ['🔥', 'nature', 'fire hot 火 燃'], + ['🌊', 'nature', 'wave ocean sea 海浪'], + ['🏔️', 'nature', 'mountain snow 雪山 山'], + // Food & drink + ['☕', 'food', 'coffee 咖啡'], + ['🍵', 'food', 'tea 茶'], + ['🧋', 'food', 'bubble tea boba 奶茶'], + ['🥛', 'food', 'milk 牛奶'], + ['🍺', 'food', 'beer 啤酒'], + ['🍷', 'food', 'wine 红酒'], + ['🥂', 'food', 'champagne cheers 香槟 干杯'], + ['🥤', 'food', 'cup straw soda 饮料 可乐'], + ['🧃', 'food', 'juice box 果汁'], + ['🍎', 'food', 'apple 苹果'], + ['🍊', 'food', 'orange tangerine 橙子 橘子'], + ['🍋', 'food', 'lemon 柠檬'], + ['🍉', 'food', 'watermelon 西瓜'], + ['🍓', 'food', 'strawberry 草莓'], + ['🍑', 'food', 'peach 桃子'], + ['🥭', 'food', 'mango 芒果'], + ['🍍', 'food', 'pineapple 菠萝'], + ['🥝', 'food', 'kiwi 猕猴桃'], + ['🍇', 'food', 'grapes 葡萄'], + ['🍒', 'food', 'cherries 樱桃'], + ['🥑', 'food', 'avocado 牛油果'], + ['🥦', 'food', 'broccoli 西兰花'], + ['🌽', 'food', 'corn 玉米'], + ['🌶️', 'food', 'hot pepper spicy 辣椒 辣'], + ['🍔', 'food', 'burger hamburger 汉堡'], + ['🍟', 'food', 'fries 薯条'], + ['🍕', 'food', 'pizza 披萨'], + ['🌭', 'food', 'hot dog 热狗'], + ['🥪', 'food', 'sandwich 三明治'], + ['🌮', 'food', 'taco 墨西哥卷'], + ['🍜', 'food', 'ramen noodles 拉面 面条'], + ['🍝', 'food', 'spaghetti pasta 意面'], + ['🍣', 'food', 'sushi 寿司'], + ['🍱', 'food', 'bento 便当'], + ['🥟', 'food', 'dumpling 饺子'], + ['🍚', 'food', 'rice 米饭'], + ['🍞', 'food', 'bread 面包'], + ['🥐', 'food', 'croissant 可颂 牛角包'], + ['🧀', 'food', 'cheese 奶酪 芝士'], + ['🍳', 'food', 'cooking egg 煎蛋 做饭'], + ['🍦', 'food', 'ice cream 冰淇淋'], + ['🍰', 'food', 'cake 蛋糕'], + ['🎂', 'food', 'birthday cake 生日蛋糕'], + ['🍫', 'food', 'chocolate 巧克力'], + ['🍩', 'food', 'donut doughnut 甜甜圈'], + ['🍪', 'food', 'cookie 饼干'], + ['🍭', 'food', 'lollipop 棒棒糖'], + // Activity & travel + ['⚽', 'activity', 'soccer football 足球'], + ['🏀', 'activity', 'basketball 篮球'], + ['🏈', 'activity', 'american football 橄榄球'], + ['⚾', 'activity', 'baseball 棒球'], + ['🎾', 'activity', 'tennis 网球'], + ['🏐', 'activity', 'volleyball 排球'], + ['🏓', 'activity', 'ping pong 乒乓球'], + ['🏸', 'activity', 'badminton 羽毛球'], + ['🥊', 'activity', 'boxing 拳击'], + ['⛳', 'activity', 'golf 高尔夫'], + ['🎣', 'activity', 'fishing 钓鱼'], + ['🏊', 'activity', 'swim 游泳'], + ['🏄', 'activity', 'surf 冲浪'], + ['🚴', 'activity', 'cycling 骑行'], + ['🏋️', 'activity', 'weightlifting gym 举重 健身'], + ['🧘', 'activity', 'yoga meditation 瑜伽 冥想'], + ['🎮', 'activity', 'video game controller 游戏 游戏机'], + ['🎲', 'activity', 'dice 骰子'], + ['🎯', 'activity', 'target bullseye 目标 靶心'], + ['🎳', 'activity', 'bowling 保龄球'], + ['🎰', 'activity', 'slot machine 老虎机'], + ['♟️', 'activity', 'chess 国际象棋 棋'], + ['🎸', 'activity', 'guitar 吉他'], + ['🎹', 'activity', 'piano keyboard 钢琴'], + ['🥁', 'activity', 'drum 鼓'], + ['🎤', 'activity', 'microphone sing 麦克风 唱歌'], + ['🎧', 'activity', 'headphones 耳机'], + ['🎬', 'activity', 'clapper movie 电影 拍摄'], + ['🎨', 'activity', 'art palette paint 画画 艺术'], + ['🎭', 'activity', 'theater masks 戏剧 面具'], + ['🎪', 'activity', 'circus 马戏团'], + ['🎡', 'activity', 'ferris wheel 摩天轮'], + ['✈️', 'activity', 'airplane travel flight 飞机 旅行'], + ['🚗', 'activity', 'car drive 汽车 车'], + ['🚕', 'activity', 'taxi 出租车'], + ['🚌', 'activity', 'bus 公交车'], + ['🚑', 'activity', 'ambulance 救护车'], + ['🚒', 'activity', 'fire engine 消防车'], + ['🚀', 'activity', 'rocket launch ship 火箭 发射'], + ['🛸', 'activity', 'ufo flying saucer 飞碟'], + ['🚲', 'activity', 'bicycle bike 自行车'], + ['🛴', 'activity', 'scooter 滑板车'], + ['🚄', 'activity', 'bullet train 高铁 动车'], + ['🚢', 'activity', 'ship 船 轮船'], + ['⛵', 'activity', 'sailboat 帆船'], + ['🏠', 'activity', 'house home 房子 家'], + ['🏢', 'activity', 'office building 公司 办公楼'], + ['🏥', 'activity', 'hospital 医院'], + ['🏫', 'activity', 'school 学校'], + ['🏖️', 'activity', 'beach vacation 海滩 度假'], + ['⛺', 'activity', 'camping tent 露营 帐篷'], + ['🌋', 'activity', 'volcano 火山'], + ['🗺️', 'activity', 'map world 地图'], + ['🧭', 'activity', 'compass 指南针'], + // Objects & work + ['💻', 'objects', 'laptop computer 电脑 笔记本'], + ['🖥️', 'objects', 'desktop computer 台式机 电脑'], + ['⌨️', 'objects', 'keyboard 键盘'], + ['🖱️', 'objects', 'computer mouse 鼠标'], + ['📱', 'objects', 'phone mobile 手机'], + ['🔋', 'objects', 'battery 电池'], + ['🔌', 'objects', 'plug electric 插头'], + ['💾', 'objects', 'floppy save 软盘 保存'], + ['📀', 'objects', 'cd disc 光盘'], + ['🎥', 'objects', 'movie camera 摄像机'], + ['📷', 'objects', 'camera 相机'], + ['🔭', 'objects', 'telescope 望远镜'], + ['📡', 'objects', 'satellite antenna 卫星 天线'], + ['🕯️', 'objects', 'candle 蜡烛'], + ['💡', 'objects', 'bulb idea light 灯泡 点子'], + ['🔦', 'objects', 'flashlight 手电筒'], + ['📁', 'objects', 'folder 文件夹'], + ['📂', 'objects', 'open folder 文件夹 打开'], + ['🗂️', 'objects', 'card index archive 归档 索引'], + ['📅', 'objects', 'calendar date 日历 日期'], + ['📌', 'objects', 'pin pushpin 图钉 置顶'], + ['📍', 'objects', 'round pin location 定位 位置'], + ['📎', 'objects', 'paperclip attachment 回形针 附件'], + ['✂️', 'objects', 'scissors cut 剪刀 剪切'], + ['📏', 'objects', 'ruler 尺子'], + ['📝', 'objects', 'memo note write 备忘 记录'], + ['📄', 'objects', 'document page 文档 文件'], + ['📃', 'objects', 'page curl 文档 文件'], + ['📑', 'objects', 'bookmark tabs 标签页 文档'], + ['📚', 'objects', 'books 书 书籍'], + ['📖', 'objects', 'open book 打开的书 阅读'], + ['🔖', 'objects', 'bookmark 书签'], + ['🏷️', 'objects', 'label tag 标签'], + ['📊', 'objects', 'bar chart stats 图表 统计'], + ['📈', 'objects', 'chart up growth 上涨 增长'], + ['📉', 'objects', 'chart down 下跌 下降'], + ['🔍', 'objects', 'search magnifier 搜索 查找'], + ['🔎', 'objects', 'search magnifier right 搜索 查找'], + ['🔒', 'objects', 'lock locked 锁 锁定'], + ['🔓', 'objects', 'unlock open 解锁'], + ['🔑', 'objects', 'key 钥匙 密钥'], + ['🔧', 'objects', 'wrench tool 扳手 工具'], + ['🔨', 'objects', 'hammer 锤子'], + ['🛠️', 'objects', 'tools hammer wrench 工具 修理'], + ['⚙️', 'objects', 'gear settings 齿轮 设置'], + ['🧲', 'objects', 'magnet 磁铁'], + ['⚗️', 'objects', 'alembic 蒸馏器 实验'], + ['🧪', 'objects', 'test tube experiment 实验 试管'], + ['🔬', 'objects', 'microscope science 显微镜 科学'], + ['🤖', 'objects', 'robot bot 机器人'], + ['👾', 'objects', 'alien monster game 外星人 游戏'], + ['💣', 'objects', 'bomb 炸弹'], + ['🧨', 'objects', 'firecracker 爆竹'], + ['🗑️', 'objects', 'trash delete 垃圾桶 删除'], + ['🧹', 'objects', 'broom clean 扫帚 清理'], + ['🧻', 'objects', 'toilet paper 纸巾'], + ['🧽', 'objects', 'sponge 海绵'], + ['📦', 'objects', 'package box 包裹 箱子'], + ['✉️', 'objects', 'envelope mail 邮件 信封'], + ['📮', 'objects', 'mailbox postbox 邮箱'], + ['🗳️', 'objects', 'ballot box vote 投票箱 投票'], + ['🔗', 'objects', 'link chain 链接 连接'], + ['🧩', 'objects', 'puzzle piece plugin 拼图 插件'], + ['🪄', 'objects', 'magic wand 魔法 魔杖'], + ['🛡️', 'objects', 'shield security 盾牌 安全'], + ['⚔️', 'objects', 'crossed swords 交叉剑 战斗'], + ['💳', 'objects', 'credit card 信用卡'], + ['💰', 'objects', 'money bag 钱袋 钱'], + ['🧾', 'objects', 'receipt 收据 小票'], + ['📿', 'objects', 'prayer beads 念珠'], + ['💍', 'objects', 'ring 戒指'], + ['👑', 'objects', 'crown 皇冠'], + ['🎩', 'objects', 'top hat 礼帽'], + ['🎒', 'objects', 'backpack 背包 书包'], + ['👓', 'objects', 'glasses 眼镜'], + ['🌂', 'objects', 'umbrella 雨伞'], + ['🕰️', 'objects', 'mantel clock 座钟'], + ['⌚', 'objects', 'watch 手表'], + ['⏱️', 'objects', 'stopwatch 秒表'], + ['🧯', 'objects', 'fire extinguisher 灭火器'], + // Symbols & status + ['✅', 'symbols', 'check done complete 完成 对勾'], + ['✔️', 'symbols', 'checkmark correct 对勾 正确'], + ['❌', 'symbols', 'cross x wrong 错误 叉'], + ['❓', 'symbols', 'question help 问题 问号'], + ['❔', 'symbols', 'white question 问题 问号'], + ['❗', 'symbols', 'exclamation important 感叹号 重要'], + ['❕', 'symbols', 'white exclamation 感叹号'], + ['⚠️', 'symbols', 'warning caution 警告 注意'], + ['🚧', 'symbols', 'construction wip 施工 进行中'], + ['🚫', 'symbols', 'prohibited no 禁止'], + ['💥', 'symbols', 'boom explosion 爆炸'], + ['✨', 'symbols', 'sparkles shiny 闪亮 星星'], + ['🎉', 'symbols', 'tada party celebrate 庆祝 撒花'], + ['🎊', 'symbols', 'confetti party 庆祝 彩带'], + ['🏆', 'symbols', 'trophy champion 奖杯 冠军'], + ['🥇', 'symbols', 'gold medal first 金牌 第一'], + ['🥈', 'symbols', 'silver medal second 银牌 第二'], + ['🥉', 'symbols', 'bronze medal third 铜牌 第三'], + ['🎖️', 'symbols', 'military medal 勋章'], + ['🚩', 'symbols', 'red flag mark 红旗 标记'], + ['🏁', 'symbols', 'checkered flag finish 终点 完成'], + ['⏳', 'symbols', 'hourglass time waiting 沙漏 时间'], + ['⌛', 'symbols', 'hourglass done 沙漏 时间'], + ['🕐', 'symbols', 'clock one time 时钟 一点'], + ['⏰', 'symbols', 'alarm clock 闹钟'], + ['🔔', 'symbols', 'bell notification 铃铛 通知'], + ['🔕', 'symbols', 'bell slash mute 静音 免打扰'], + ['🕹️', 'symbols', 'joystick game 摇杆 游戏'], + ['🔴', 'symbols', 'red circle record 红圆 录制'], + ['🟢', 'symbols', 'green circle online 绿圆 在线'], + ['🟡', 'symbols', 'yellow circle 黄圆'], + ['🟠', 'symbols', 'orange circle 橙圆'], + ['🔵', 'symbols', 'blue circle 蓝圆'], + ['🟣', 'symbols', 'purple circle 紫圆'], + ['⚫', 'symbols', 'black circle 黑圆'], + ['⚪', 'symbols', 'white circle 白圆'], + ['🟥', 'symbols', 'red square 红方'], + ['🟩', 'symbols', 'green square 绿方'], + ['🟦', 'symbols', 'blue square 蓝方'], + ['🔺', 'symbols', 'red triangle up 三角 上'], + ['🔻', 'symbols', 'triangle down 三角 下'], + ['🔸', 'symbols', 'diamond orange 菱形'], + ['🔹', 'symbols', 'diamond blue 菱形'], + ['💠', 'symbols', 'diamond dot 菱形 花'], + ['🔶', 'symbols', 'diamond orange big 菱形'], + ['🔷', 'symbols', 'diamond blue big 菱形'], + ['▶️', 'symbols', 'play 播放'], + ['⏸️', 'symbols', 'pause 暂停'], + ['⏹️', 'symbols', 'stop 停止'], + ['⏺️', 'symbols', 'record 录制'], + ['⏩', 'symbols', 'fast forward 快进'], + ['⏪', 'symbols', 'rewind 快退'], + ['🔀', 'symbols', 'shuffle 随机 打乱'], + ['🔁', 'symbols', 'repeat 重复 循环'], + ['🔂', 'symbols', 'repeat one 单曲循环'], + ['🔄', 'symbols', 'refresh sync 刷新 同步'], + ['🔃', 'symbols', 'reload 重载'], + ['➕', 'symbols', 'plus add 加 新增'], + ['➖', 'symbols', 'minus 减'], + ['➗', 'symbols', 'divide 除'], + ['✖️', 'symbols', 'multiply 乘'], + ['💲', 'symbols', 'dollar money 美元 钱'], + ['™️', 'symbols', 'trademark 商标'], + ['©️', 'symbols', 'copyright 版权'], + ['®️', 'symbols', 'registered 注册商标'], + ['↔️', 'symbols', 'left right arrow 左右箭头'], + ['⬆️', 'symbols', 'up arrow 上箭头'], + ['⬇️', 'symbols', 'down arrow 下箭头'], + ['➡️', 'symbols', 'right arrow 右箭头'], + ['⬅️', 'symbols', 'left arrow 左箭头'], + ['🔙', 'symbols', 'back 返回'], + ['🔜', 'symbols', 'soon 很快'], + ['🔝', 'symbols', 'top 置顶 顶部'], + ['💤', 'symbols', 'zzz sleep 睡觉'], + ['🆕', 'symbols', 'new 新 新品'], + ['🆒', 'symbols', 'cool 酷'], + ['🆓', 'symbols', 'free 免费'], + ['🆗', 'symbols', 'ok 可以'], + ['🆙', 'symbols', 'up 提升'], + ['🆚', 'symbols', 'vs versus 对比'], + ['♾️', 'symbols', 'infinity 无限'], + ['💯', 'symbols', 'hundred perfect 满分 一百'], + ['💢', 'symbols', 'anger 生气'], + ['♨️', 'symbols', 'hot springs 温泉'], + ['🚸', 'symbols', 'children crossing 注意儿童'], + ['🔞', 'symbols', 'no one under eighteen 十八禁'], + ['📵', 'symbols', 'no mobile phones 禁止手机'], + ['❤️', 'symbols', 'red heart love 红心 爱'], + ['🧡', 'symbols', 'orange heart 橙心'], + ['💛', 'symbols', 'yellow heart 黄心'], + ['💚', 'symbols', 'green heart 绿心'], + ['💙', 'symbols', 'blue heart 蓝心'], + ['💜', 'symbols', 'purple heart 紫心'], + ['🖤', 'symbols', 'black heart 黑心'], + ['🤍', 'symbols', 'white heart 白心'], + ['🤎', 'symbols', 'brown heart 棕心'], + ['💔', 'symbols', 'broken heart 心碎'], + ['💕', 'symbols', 'two hearts 双心 爱心'], + ['💖', 'symbols', 'sparkling heart 闪亮的心'], + ['💗', 'symbols', 'growing heart 心动'], +]; + +export interface EmojiEntry { + emoji: string; + group: EmojiGroup; + /** Lowercase space-separated keywords; matched by substring. */ + keywords: string; +} + +export const EMOJI_ENTRIES: readonly EmojiEntry[] = DATA.map(([emoji, group, keywords]) => ({ + emoji, + group, + keywords, +})); + +/** + * Filter the dataset by a lowercase-insensitive substring of the keywords + * (zh or en), capped at `limit` entries in dataset order. An empty query + * yields no results — the picker falls back to its grouped grid then. + */ +export function searchEmojis(query: string, limit = 24): string[] { + const q = query.trim().toLowerCase(); + if (!q) return []; + const out: string[] = []; + for (const entry of EMOJI_ENTRIES) { + if (entry.keywords.includes(q) || entry.emoji === q) { + out.push(entry.emoji); + if (out.length >= limit) break; + } + } + return out; +} + +/** Cap on the picker's recently-used row. */ +export const RECENT_EMOJIS_MAX = 8; + +/** Prepend `emoji` to the recent list, deduplicated and capped at `max`. */ +export function pushRecentEmoji( + list: readonly string[], + emoji: string, + max = RECENT_EMOJIS_MAX, +): string[] { + return [emoji, ...list.filter((e) => e !== emoji)].slice(0, max); +} diff --git a/packages/web-core/src/lib/index.ts b/packages/web-core/src/lib/index.ts new file mode 100644 index 000000000..9d8b45e8e --- /dev/null +++ b/packages/web-core/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from './emojiData'; +export * from './sessionEmoji'; diff --git a/packages/web-core/src/lib/sessionEmoji.ts b/packages/web-core/src/lib/sessionEmoji.ts new file mode 100644 index 000000000..a6ffbd81a --- /dev/null +++ b/packages/web-core/src/lib/sessionEmoji.ts @@ -0,0 +1,55 @@ +// web-core lib/sessionEmoji — a session's "icon" is the leading emoji cluster +// of its title; these helpers are the one parse/write rule for it. + +// Constructed lazily: the lib must stay side-effect-free for every importer, +// and environments without Intl.Segmenter degrade to "no icon" instead of +// throwing during sidebar rendering. +let graphemeSegmenter: Intl.Segmenter | undefined; + +function graphemes(title: string): Intl.Segments | undefined { + if (typeof Intl.Segmenter !== 'function') return undefined; + graphemeSegmenter ??= new Intl.Segmenter('und', { granularity: 'grapheme' }); + return graphemeSegmenter.segment(title); +} + +const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u; +const REGIONAL_INDICATOR_RE = /\p{Regional_Indicator}/u; +const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u; +const VARIATION_SELECTOR_16 = '\uFE0F'; + +// Detection is deliberately conservative to avoid false positives on plain +// text: Emoji_Presentation code points, Regional_Indicator pairs (flags), or +// Extended_Pictographic + VS16 (⚠️ as typed by OS emoji pickers). Bare ASCII +// digits / '#' / '*' are Extended_Pictographic but are NOT icons, and neither +// are text-presentation marks (bare "⚠", "❤") or keycap sequences. +function isEmojiCluster(cluster: string): boolean { + if (EMOJI_PRESENTATION_RE.test(cluster)) return true; + if (REGIONAL_INDICATOR_RE.test(cluster)) return true; + return EXTENDED_PICTOGRAPHIC_RE.test(cluster) && cluster.includes(VARIATION_SELECTOR_16); +} + +export interface SessionEmojiSplit { + /** Leading emoji cluster, or null when the title starts with plain text. */ + emoji: string | null; + /** Title text with the emoji prefix (and one separating run of spaces) removed. */ + rest: string; +} + +/** Split a session title into its leading emoji icon and the remaining text. */ +export function splitSessionEmoji(title: string): SessionEmojiSplit { + const first = graphemes(title)?.[Symbol.iterator]().next().value; + if (first === undefined || !isEmojiCluster(first.segment)) return { emoji: null, rest: title }; + const rest = title.slice(first.index + first.segment.length).replace(/^\s+/, ''); + return { emoji: first.segment, rest }; +} + +/** + * Return the title with its emoji prefix replaced / inserted / removed + * (`emoji: null`). The title text itself is left untouched. + */ +export function applySessionEmoji(title: string, emoji: string | null): string { + const { rest } = splitSessionEmoji(title); + const next = emoji?.trim() ?? ''; + if (!next) return rest; + return rest ? `${next} ${rest}` : next; +} diff --git a/packages/web-core/test/emojiData.test.ts b/packages/web-core/test/emojiData.test.ts new file mode 100644 index 000000000..97125a210 --- /dev/null +++ b/packages/web-core/test/emojiData.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { EMOJI_ENTRIES, pushRecentEmoji, RECENT_EMOJIS_MAX, searchEmojis } from '../src/lib/emojiData'; +import { splitSessionEmoji } from '../src/lib/sessionEmoji'; + +describe('searchEmojis', () => { + it('matches English keywords case-insensitively', () => { + expect(searchEmojis('FIRE')).toContain('🔥'); + expect(searchEmojis('rocket')).toEqual(['🚀']); + }); + + it('matches Chinese keywords by substring', () => { + expect(searchEmojis('咖啡')).toEqual(['☕']); + expect(searchEmojis('电脑')).toEqual(expect.arrayContaining(['💻', '🖥️'])); + }); + + it('matches partial keywords', () => { + // "chart" is a prefix of both bar-chart and trend entries. + const hits = searchEmojis('chart'); + expect(hits).toEqual(expect.arrayContaining(['📊', '📈', '📉'])); + }); + + it('returns an empty array for an empty query', () => { + expect(searchEmojis('')).toEqual([]); + expect(searchEmojis(' ')).toEqual([]); + }); + + it('returns an empty array when nothing matches', () => { + expect(searchEmojis('qqqzzz')).toEqual([]); + }); + + it('caps the results at the limit', () => { + expect(searchEmojis('圆', 2)).toHaveLength(2); + }); +}); + +describe('pushRecentEmoji', () => { + it('prepends the emoji', () => { + expect(pushRecentEmoji(['🐛', '⏳'], '🔥')).toEqual(['🔥', '🐛', '⏳']); + }); + + it('deduplicates an existing entry to the front', () => { + expect(pushRecentEmoji(['🐛', '⏳', '🔥'], '🔥')).toEqual(['🔥', '🐛', '⏳']); + }); + + it('caps the list at the max', () => { + const full = Array.from({ length: RECENT_EMOJIS_MAX }, (_, i) => `e${i}`); + const next = pushRecentEmoji(full, '🔥'); + expect(next).toHaveLength(RECENT_EMOJIS_MAX); + expect(next[0]).toBe('🔥'); + expect(next.at(-1)).toBe(`e${RECENT_EMOJIS_MAX - 2}`); + }); +}); + +describe('EMOJI_ENTRIES', () => { + it('every entry round-trips through splitSessionEmoji as the session icon', () => { + for (const { emoji } of EMOJI_ENTRIES) { + expect(splitSessionEmoji(`${emoji} 标题`).emoji).toBe(emoji); + } + }); + + it('has no duplicate entries', () => { + const all = EMOJI_ENTRIES.map((e) => e.emoji); + expect(new Set(all).size).toBe(all.length); + }); +}); diff --git a/packages/web-core/test/sessionEmoji.test.ts b/packages/web-core/test/sessionEmoji.test.ts new file mode 100644 index 000000000..3c00f7d04 --- /dev/null +++ b/packages/web-core/test/sessionEmoji.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { applySessionEmoji, splitSessionEmoji } from '../src/lib/sessionEmoji'; + +describe('splitSessionEmoji', () => { + it('returns null emoji for plain titles', () => { + expect(splitSessionEmoji('做一个 desktop 的需求')).toEqual({ + emoji: null, + rest: '做一个 desktop 的需求', + }); + }); + + it('splits a leading emoji and its separating space', () => { + expect(splitSessionEmoji('⏳ 做一个 desktop 的需求')).toEqual({ + emoji: '⏳', + rest: '做一个 desktop 的需求', + }); + }); + + it('handles VS16 sequences (⚠️ = U+26A0 U+FE0F) as one emoji', () => { + const { emoji, rest } = splitSessionEmoji('⚠️ 排查一个 bug'); + expect(emoji).toBe('⚠️'); + expect(rest).toBe('排查一个 bug'); + }); + + it('handles ZWJ sequences as a single cluster', () => { + const { emoji, rest } = splitSessionEmoji('👨‍👩‍👧 家庭事项'); + expect(emoji).toBe('👨‍👩‍👧'); + expect(rest).toBe('家庭事项'); + }); + + it('handles flags (Regional_Indicator pairs)', () => { + const { emoji, rest } = splitSessionEmoji('🇨🇳 本地化'); + expect(emoji).toBe('🇨🇳'); + expect(rest).toBe('本地化'); + }); + + it('returns an empty rest for emoji-only titles', () => { + expect(splitSessionEmoji('⏳')).toEqual({ emoji: '⏳', rest: '' }); + }); + + it('strips a run of spaces after the emoji', () => { + expect(splitSessionEmoji('🔥 紧急')).toEqual({ emoji: '🔥', rest: '紧急' }); + }); + + it('does not treat ASCII digits as emoji (Extended_Pictographic false positive)', () => { + expect(splitSessionEmoji('2 个 bug')).toEqual({ emoji: null, rest: '2 个 bug' }); + expect(splitSessionEmoji('# 标签')).toEqual({ emoji: null, rest: '# 标签' }); + }); + + it('does not treat text-presentation marks without VS16 as icons', () => { + const title = '⚠ 裸警告符'; + expect(splitSessionEmoji(title)).toEqual({ emoji: null, rest: title }); + }); + + it('leaves titles with leading whitespace untouched', () => { + const title = ' ⏳ 前导空格'; + expect(splitSessionEmoji(title)).toEqual({ emoji: null, rest: title }); + }); + + it('handles empty titles', () => { + expect(splitSessionEmoji('')).toEqual({ emoji: null, rest: '' }); + }); + + it('degrades to "no icon" when Intl.Segmenter is unavailable', () => { + const original = Intl.Segmenter; + // @ts-expect-error deliberately simulating a webview without the API + Intl.Segmenter = undefined; + try { + expect(splitSessionEmoji('⏳ 标题')).toEqual({ emoji: null, rest: '⏳ 标题' }); + expect(applySessionEmoji('⏳ 标题', '🔥')).toBe('🔥 ⏳ 标题'); + } finally { + Intl.Segmenter = original; + } + }); +}); + +describe('applySessionEmoji', () => { + it('prepends an emoji to a plain title', () => { + expect(applySessionEmoji('新标题', '🎯')).toBe('🎯 新标题'); + }); + + it('replaces an existing emoji', () => { + expect(applySessionEmoji('⏳ 旧', '🔥')).toBe('🔥 旧'); + }); + + it('removes the emoji when null', () => { + expect(applySessionEmoji('⏳ 旧', null)).toBe('旧'); + }); + + it('is a no-op when removing from a plain title', () => { + expect(applySessionEmoji('旧', null)).toBe('旧'); + }); + + it('does not emit a trailing space when the text is empty', () => { + expect(applySessionEmoji('⏳', '🔥')).toBe('🔥'); + expect(applySessionEmoji('', '⏳')).toBe('⏳'); + }); + + it('round-trips through splitSessionEmoji', () => { + for (const [title, emoji] of [ + ['做一个 desktop 的需求', '🎨'], + ['⏳ 旧', '🇨🇳'], + ['⚠️ 排查 bug', '✨'], + ] as const) { + const next = applySessionEmoji(title, emoji); + expect(splitSessionEmoji(next)).toEqual({ emoji, rest: splitSessionEmoji(title).rest }); + } + }); +}); diff --git a/packages/web-i18n/src/locales/en/sidebar.ts b/packages/web-i18n/src/locales/en/sidebar.ts index 45f1d98ea..a254f3f76 100644 --- a/packages/web-i18n/src/locales/en/sidebar.ts +++ b/packages/web-i18n/src/locales/en/sidebar.ts @@ -15,6 +15,19 @@ export default { archiveConfirm: 'Archive this session? You can restore it later from Settings.', options: 'Options', rename: 'Rename', + setEmoji: 'Set Emoji…', + sessionEmojiTitle: 'Pick an emoji', + removeEmoji: 'Remove emoji', + randomEmoji: 'Random', + searchEmoji: 'Search emoji', + recentEmojis: 'Recently used', + noEmojiResults: 'No matching emoji', + emojiGroupFaces: 'Smileys & People', + emojiGroupNature: 'Animals & Nature', + emojiGroupFood: 'Food & Drink', + emojiGroupActivity: 'Activities & Travel', + emojiGroupObjects: 'Objects & Work', + emojiGroupSymbols: 'Symbols & Status', copyPath: 'Copy path', copySessionId: 'Copy session ID', copied: 'Copied ✓', diff --git a/packages/web-i18n/src/locales/zh/sidebar.ts b/packages/web-i18n/src/locales/zh/sidebar.ts index 9244532df..b9ef63b09 100644 --- a/packages/web-i18n/src/locales/zh/sidebar.ts +++ b/packages/web-i18n/src/locales/zh/sidebar.ts @@ -15,6 +15,19 @@ export default { archiveConfirm: '确认归档会话?归档后可以从「设置」中恢复', options: '选项', rename: '重命名', + setEmoji: '设置 Emoji…', + sessionEmojiTitle: '选择 Emoji', + removeEmoji: '移除 Emoji', + randomEmoji: '随机', + searchEmoji: '搜索 Emoji', + recentEmojis: '最近使用', + noEmojiResults: '没有匹配的 Emoji', + emojiGroupFaces: '笑脸与人物', + emojiGroupNature: '动物与自然', + emojiGroupFood: '美食饮品', + emojiGroupActivity: '活动与出行', + emojiGroupObjects: '物品与工作', + emojiGroupSymbols: '符号与状态', copyPath: '复制路径', copySessionId: '复制 Session ID', copied: '已复制 ✓', diff --git a/packages/web-ui/src/components/ui/Menu.vue b/packages/web-ui/src/components/ui/Menu.vue index befb1bd83..44e99934a 100644 --- a/packages/web-ui/src/components/ui/Menu.vue +++ b/packages/web-ui/src/components/ui/Menu.vue @@ -4,6 +4,10 @@ diff --git a/packages/web-ui/src/components/ui/MenuItem.vue b/packages/web-ui/src/components/ui/MenuItem.vue index 9397f7780..e1b112f3b 100644 --- a/packages/web-ui/src/components/ui/MenuItem.vue +++ b/packages/web-ui/src/components/ui/MenuItem.vue @@ -8,7 +8,9 @@ withDefaults(defineProps<{ separator?: boolean; /** md (desktop) · lg (touch / mobile, ≥44px row). */ size?: 'md' | 'lg'; -}>(), { size: 'md' }); + /** `menuitem` inside a Menu (default); `button` in non-menu popovers (e.g. dialogs). */ + role?: 'menuitem' | 'button'; +}>(), { size: 'md', role: 'menuitem' }); defineEmits<{ click: [event: MouseEvent] }>(); @@ -20,7 +22,7 @@ defineEmits<{ click: [event: MouseEvent] }>(); class="ui-menu-item" :class="[`ui-menu-item--${size}`, { 'is-active': active, 'is-danger': danger }]" type="button" - role="menuitem" + :role="role" :disabled="disabled" @click="$emit('click', $event)" >