feat(sidebar): session emoji icon with searchable picker (#97)

* feat(sidebar): session emoji icon with searchable picker

A session's icon is the leading emoji cluster of its title (web-core
splitSessionEmoji/applySessionEmoji) — no icon field, every client renders
the title as-is, and writing reuses the existing rename path.

SessionRow grows a kebab-menu 'Set Emoji…' entry (the touch/keyboard path)
plus a quiet hover wash on the title's emoji; both open SessionEmojiPicker,
a Menu-shelled panel with a bare search row (zh/en keyword dataset, ~400
grouped entries), a Recently-used row (localStorage, cap 8), and
remove/random MenuItems. Inline rename edits only the title text and
re-applies the icon on commit.

DesignSystemView documents the pattern (§07 session row + no-emoji-icon's
user-content scope); the two apps' copies of the row/picker stay in sync.

* fix(sidebar): emoji picker review fixes

- Unchanged text in the inline rename now skips the PATCH entirely, so
  stored titles that don't use the normalized "emoji + space" shape
  (e.g. "🔥bug") are kept byte-for-byte instead of being rewritten.
- The picker's outside-click handler ignores the trigger element, so
  clicking the title emoji / kebab again toggles the picker closed
  instead of close-then-reopen.
- Enter during IME composition no longer picks the first search result
  (useImeComposition guard on the search input).
- Picker paddings/gaps moved onto --space-* tokens.

* fix(sidebar): emoji picker review fixes, round 2

- The picker's Escape listener now runs in the capture phase and consumes
  the key, so dismissing the picker can't reach the conversation pane's
  bubble-phase handler and interrupt / undo the active turn.
- The title emoji trigger is a real <button> (keyboard-focusable, Enter /
  Space operate it, --p-focus-ring on focus-visible).
- "Set Emoji…" invoked from the row's right-click menu anchors the picker
  to the menu's position (captured before closeMenu unmounts it) instead
  of jumping to the hidden kebab.
- Picker grid gap/padding moved onto --space-* (cell size keeps the 26px
  IconButton-sm footprint).
- web-core lib module banners trimmed to local concerns (the storage
  rationale lives in the PR description / design notes).

* fix(sidebar): emoji picker review fixes, round 3

- Picker Escape now registers on window capture: the topmost layer eats
  the key ahead of the side panel (document capture) and the conversation
  interrupt (document bubble), so one press dismisses exactly one layer.
- Menu gains a role prop ('menu' | 'dialog'); the picker uses 'dialog' —
  its content is a search input + button grid, not menuitems.
- Intl.Segmenter is constructed lazily so the web-core root import graph
  stays side-effect-free.
- Emoji cells use --text-lg; module/component comment banners trimmed.

* fix(sidebar): emoji picker review fixes, round 4

- Keydowns inside the picker panel stop at the panel, so global shortcuts
  (session search / new chat) can't fire behind the popover while typing.
- MenuItem gains a role prop; the picker's dialog footer uses 'button'.
- Re-picking the current emoji returns early — no PATCH even for stored
  titles whose prefix isn't in the normalized shape.
- Empty-state padding moved onto --space-*; comments trimmed further.

* fix(sidebar): emoji picker review fixes, round 5

- The row renders the title's stored suffix byte-for-byte (separator
  included) instead of synthesizing one space, matching what chat header /
  search / export show for non-normalized titles.
- Emoji cell drops a redundant line-height (grid centering owns the box).
- The emoji lib moves off the web-core root barrel into the ./lib subpath
  (same pattern as ./api), keeping the root import graph lean.

* fix(sidebar): drop the session emoji hover wash

* fix(sidebar): emoji picker review fixes, round 6

- The picker's Escape consults the search input's shared IME guard (exposed
  by the picker), so cancelling an IME candidate never closes the picker —
  including Safari's isComposing=false case.
- splitSessionEmoji degrades to "no icon" when Intl.Segmenter is missing
  instead of throwing during sidebar rendering (with a fallback test).

* fix(sidebar): emoji picker review fixes, round 7

- Emoji cells get a --p-focus-ring focus-visible state for keyboard users.
- lib/sessionEmoji header comment trimmed to the implementation invariant.

* fix(sidebar): edit the whole title in inline rename, emoji included

The emoji is an ordinary character of the title string — show it in the
input and let the user edit it like any other text. The icon is whatever
the committed title parses to, so the composition gymnastics (rest-only
editing, re-apply on commit, typed-emoji-wins) all go away.

* fix(sidebar): corner-aware emoji picker anchoring

The panel now pops from the corner nearest the trigger: left-side
triggers (the title emoji, the right-click menu's rect) align left and
pop from top-left as before, while the kebab path right-aligns to the
button and pops from top-right — the same pop language as the kebab's
own dropdown. Upward flips swap in the matching bottom corner.

* fix(sidebar): pointer-anchored emoji picker

The panel now anchors to the click point itself, context-menu style:
opens downward from the pointer, flips upward when space below runs out,
and after horizontal clamping the transform origin is recomputed to the
click's position inside the panel, so the pop always grows out of the
pointer. Keyboard-triggered opens (clientX/Y = 0) keep the previous
trigger-rect anchoring.

* fix(sidebar): trim the pointer-anchoring comment to the invariant

* fix(sidebar): drive picker metrics from one --ep-cell variable

The grid columns now size to the cell (IconButton-sm footprint), the input
and scroll heights derive from it, and the panel width follows the content
— no hard-coded dimensions left in the picker.
This commit is contained in:
qer 2026-07-24 13:51:29 +08:00 committed by GitHub
parent 43624a3ea1
commit a818544df0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2301 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
支持为会话设置 Emoji 图标(选择器可搜索、显示最近使用),在会话选项菜单选择「设置 Emoji…」或点击标题前的 Emoji 即可使用。

View file

@ -0,0 +1,241 @@
<!-- apps/web/src/components/SessionEmojiPicker.vue -->
<!-- Session emoji picker popover (SessionRow's "Set Emoji…" entry / title emoji
click): bare search row scrollable sections (Recently used from
localStorage, cap 8 the grouped dataset) MenuItem footer. -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Icon, Menu, MenuItem, useImeComposition } from '@moonshot-ai/web-ui';
import {
EMOJI_ENTRIES,
EMOJI_GROUPS,
pushRecentEmoji,
searchEmojis,
type EmojiGroup,
} from '@moonshot-ai/web-core/lib';
const { t } = useI18n();
// IME guard: Enter that only confirms a composition candidate must not pick.
const { handleCompositionStart, handleCompositionEnd, isComposingKeyEvent } = useImeComposition();
const props = withDefaults(
defineProps<{
current?: string | null;
/** False when the title is emoji-only: removing would leave an empty title. */
removable?: boolean;
}>(),
{ current: null, removable: true },
);
const emit = defineEmits<{ pick: [emoji: string | null] }>();
// Random's pool: a small curated set of safe bets (all round-trip through
// splitSessionEmoji's conservative detection).
const RANDOM_POOL = [
'⏳', '⚠️', '🐛', '✨', '🔥', '🚀', '🎯', '🧪',
'📝', '🔍', '🛠️', '💡', '📦', '🎨', '🔒', '📈',
'🧹', '🚧', '✅', '❓', '🌙', '☕', '🐳', '🗂️',
'📊', '🤖', '🧩', '⚙️', '🌱', '📌', '💥', '🕐',
];
const GROUP_LABEL_KEY: Record<EmojiGroup, string> = {
faces: 'sidebar.emojiGroupFaces',
nature: 'sidebar.emojiGroupNature',
food: 'sidebar.emojiGroupFood',
activity: 'sidebar.emojiGroupActivity',
objects: 'sidebar.emojiGroupObjects',
symbols: 'sidebar.emojiGroupSymbols',
};
const grouped = EMOJI_GROUPS.map((id) => ({
id,
labelKey: GROUP_LABEL_KEY[id],
emojis: EMOJI_ENTRIES.filter((e) => e.group === id).map((e) => e.emoji),
}));
const STORAGE_KEY = 'kimi-web.recent-emojis';
const recents = ref<string[]>(readRecents());
function readRecents(): string[] {
try {
const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
return Array.isArray(raw) ? raw.filter((e): e is string => typeof e === 'string') : [];
} catch {
return [];
}
}
function onPick(emoji: string): void {
recents.value = pushRecentEmoji(recents.value, emoji);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(recents.value));
} catch {
// Quota / private mode: recents just don't persist.
}
emit('pick', emoji);
}
const query = ref('');
const searching = computed(() => query.value.trim().length > 0);
const results = computed(() => searchEmojis(query.value));
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => inputRef.value?.focus());
// Enter picks the first search result.
function onEnter(e: KeyboardEvent): void {
if (isComposingKeyEvent(e)) return;
const first = results.value[0];
if (searching.value && first) onPick(first);
}
function pickRandom(): void {
let next: string | undefined = props.current ?? undefined;
while (next === undefined || next === props.current) {
next = RANDOM_POOL[Math.floor(Math.random() * RANDOM_POOL.length)];
}
onPick(next);
}
// Mirror Menu/IconButton's exposed-el pattern so the consumer can outside-click
// against the panel (positioning is left to the consumer, same as Menu); the
// IME guard is shared for the consumer's Escape handling (Safari reports
// isComposing=false on the candidate-cancelling Escape).
const menuRef = ref<InstanceType<typeof Menu> | null>(null);
defineExpose({ el: computed(() => menuRef.value?.el), isComposingKeyEvent });
</script>
<template>
<Menu ref="menuRef" class="emoji-picker" role="dialog" :aria-label="t('sidebar.sessionEmojiTitle')" @keydown.stop>
<div class="ep-search">
<Icon name="search" size="sm" />
<input
ref="inputRef"
v-model="query"
class="ep-input"
type="text"
:placeholder="t('sidebar.searchEmoji')"
autocomplete="off"
spellcheck="false"
@keydown.enter="onEnter"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
<div class="ep-scroll">
<template v-if="searching">
<div v-if="results.length" class="ep-grid">
<button
v-for="e in results"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
<div v-else class="ep-empty">{{ t('sidebar.noEmojiResults') }}</div>
</template>
<template v-else>
<template v-if="recents.length">
<div class="ep-label">{{ t('sidebar.recentEmojis') }}</div>
<div class="ep-grid">
<button
v-for="e in recents"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
</template>
<template v-for="g in grouped" :key="g.id">
<div class="ep-label">{{ t(g.labelKey) }}</div>
<div class="ep-grid">
<button
v-for="e in g.emojis"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
</template>
</template>
</div>
<MenuItem separator />
<MenuItem role="button" :disabled="!(current && removable)" @click="emit('pick', null)">
<Icon name="close" size="sm" />
{{ t('sidebar.removeEmoji') }}
</MenuItem>
<MenuItem role="button" @click="pickRandom">
<Icon name="sparkles" size="sm" />
{{ t('sidebar.randomEmoji') }}
</MenuItem>
</Menu>
</template>
<style scoped>
/* Cell size drives every picker metric (matches the IconButton-sm footprint):
8-column grid + derived input height, scroll height and panel width. */
.emoji-picker { --ep-cell: 26px; }
/* Bare list-style search row (the sidebar Search vocabulary): icon + input,
no border; the row shows a sunken wash on hover / focus-within. */
.ep-search {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-1);
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
color: var(--color-text-faint);
}
.ep-search:hover,
.ep-search:focus-within { background: var(--color-surface-sunken); }
.ep-input {
flex: 1;
min-width: 0;
height: calc(var(--ep-cell) + 2px);
font-size: var(--text-sm);
color: var(--color-text);
background: transparent;
border: none;
outline: none;
}
.ep-input::placeholder { color: var(--color-text-faint); }
.ep-scroll { max-height: calc(var(--ep-cell) * 10 + var(--space-1)); overflow-y: auto; padding: 0 var(--space-1); }
/* Section labels — the .side-section-label recipe (xs / 600 / uppercase / faint). */
.ep-label {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-section-label);
text-transform: uppercase;
color: var(--color-text-faint);
user-select: none;
}
.ep-grid { display: grid; grid-template-columns: repeat(8, var(--ep-cell)); gap: var(--space-1); padding-bottom: var(--space-1); }
.ep-e {
height: var(--ep-cell);
display: grid;
place-items: center;
padding: 0;
font-size: var(--text-lg);
background: transparent;
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
}
.ep-e:hover { background: var(--color-hover); }
.ep-e:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ep-e.sel { background: var(--color-accent-soft); }
.ep-empty {
padding: var(--space-3) var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-faint);
text-align: center;
user-select: none;
}
</style>

View file

@ -1,12 +1,15 @@
<!-- apps/web/src/components/SessionRow.vue -->
<!-- A single session row: status dot + title + time + attention pill + kebab. -->
<!-- Inline rename (dblclick) and delete-confirm live here. -->
<!-- Inline rename (dblclick), the emoji icon affordance (hover wash + picker, -->
<!-- see SessionEmojiPicker) and delete-confirm live here. -->
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session } from '../types';
import { copyTextToClipboard } from '../lib/clipboard';
import { Badge, Icon, IconButton, Menu, MenuItem, Spinner, Tooltip, useImeComposition } from '@moonshot-ai/web-ui';
import { applySessionEmoji, splitSessionEmoji } from '@moonshot-ai/web-core/lib';
import SessionEmojiPicker from './SessionEmojiPicker.vue';
const { t } = useI18n();
@ -109,6 +112,7 @@ async function toggleMenu(e: Event): Promise<void> {
// caller then anchors it the button for toggleMenu, the cursor for
// right-click.
async function openMenu(): Promise<void> {
closePicker();
menuOpen.value = true;
// Defer so the current click doesn't immediately close the menu.
setTimeout(() => document.addEventListener('mousedown', onDocClick), 0);
@ -124,9 +128,137 @@ function closeMenu(): void {
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('mousedown', onPickerDocClick);
window.removeEventListener('keydown', onPickerKeydown, true);
window.removeEventListener('resize', closeMenu);
window.removeEventListener('resize', closePicker);
});
// Emoji picker picking rewrites the title's leading emoji cluster (web-core
// splitSessionEmoji) through the ordinary rename path.
const emojiSplit = computed(() => splitSessionEmoji(props.session.title));
/** Title text after the emoji button — the stored separator + text, byte-for-byte. */
const displayText = computed(() => {
const e = emojiSplit.value.emoji;
return e ? props.session.title.slice(e.length) : props.session.title;
});
const pickerOpen = ref(false);
const pickerRef = ref<InstanceType<typeof SessionEmojiPicker> | null>(null);
const pickerStyle = ref<Record<string, string>>({});
/** Element the picker is anchored to — its own clicks toggle, so outside-click ignores it. */
let pickerAnchor: HTMLElement | null = null;
// Pointer-anchored: transform origin tracks the click even after clamping.
// Keyboard "clicks" (clientX/Y = 0) fall back to the trigger rect's `side` corner.
function positionPicker(r: DOMRect, side: 'left' | 'right', originX?: number): void {
const panel = pickerRef.value?.el;
const gap = 4;
const margin = 8;
const panelH = panel?.offsetHeight ?? 0;
const panelW = panel?.offsetWidth ?? 0;
let top = r.bottom + gap;
let flipped = false;
if (top + panelH > window.innerHeight - margin) {
top = Math.max(margin, r.top - panelH - gap);
flipped = true;
}
const wantLeft = originX ?? (side === 'left' ? r.left : r.right - panelW);
const left = Math.max(margin, Math.min(wantLeft, window.innerWidth - panelW - margin));
const originXPart =
originX === undefined ? side : `${Math.round(Math.min(Math.max(originX - left, 0), panelW))}px`;
pickerStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
transformOrigin: `${originXPart} ${flipped ? 'bottom' : 'top'}`,
'--menu-pop-shift': flipped ? '2px' : '-2px',
};
}
async function openPicker(
anchor: HTMLElement | undefined,
rect?: DOMRect,
side: 'left' | 'right' = 'left',
originX?: number,
): Promise<void> {
const r = rect ?? anchor?.getBoundingClientRect();
if (!r) return;
if (pickerOpen.value) {
closePicker();
return;
}
closeMenu();
pickerAnchor = anchor ?? null;
pickerOpen.value = true;
// Defer so the opening click doesn't immediately close the panel.
setTimeout(() => document.addEventListener('mousedown', onPickerDocClick), 0);
// Window capture, consumed: the top layer owns Escape ahead of the side
// panel (document capture) and the conversation interrupt (document bubble).
window.addEventListener('keydown', onPickerKeydown, true);
window.addEventListener('resize', closePicker);
// Wait for the teleported panel to mount so its size can be measured.
await nextTick();
positionPicker(r, side, originX);
}
function closePicker(): void {
pickerOpen.value = false;
pickerAnchor = null;
document.removeEventListener('mousedown', onPickerDocClick);
window.removeEventListener('keydown', onPickerKeydown, true);
window.removeEventListener('resize', closePicker);
}
function onPickerDocClick(e: MouseEvent): void {
const target = e.target as Node;
if (pickerRef.value?.el?.contains(target)) return;
// The trigger's own click toggles the picker leave it for openPicker,
// otherwise the mousedown closes and the click immediately reopens.
if (pickerAnchor?.contains(target)) return;
closePicker();
}
function onPickerKeydown(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
// An Escape that only cancels an IME candidate in the search box must not
// close the picker (the picker's own guard covers Safari's isComposing=false).
if (pickerRef.value?.isComposingKeyEvent(e)) return;
e.preventDefault();
e.stopPropagation();
closePicker();
}
function pointRect(e: MouseEvent): DOMRect | undefined {
return e.clientX || e.clientY ? new DOMRect(e.clientX, e.clientY, 0, 0) : undefined;
}
function openPickerFromRow(e: Event): void {
e.stopPropagation();
const me = e as MouseEvent;
void openPicker(me.currentTarget as HTMLElement, pointRect(me), 'left', me.clientX || undefined);
}
function openPickerFromMenu(e: Event): void {
// Right-click opened the menu at the cursor: anchor the picker to the click
// point (or, as a keyboard fallback, the menu's rect captured before
// closeMenu unmounts it), not to the hidden kebab.
const fromCursor = menuAnchor.value === 'cursor';
const anchor = fromCursor ? menuRef.value?.el : kebabRef.value?.el;
const me = e as MouseEvent;
const rect = pointRect(me) ?? anchor?.getBoundingClientRect();
closeMenu();
void openPicker(anchor, rect, fromCursor ? 'left' : 'right', me.clientX || undefined);
}
function applyEmoji(emoji: string | null): void {
closePicker();
// Re-picking the current icon is a no-op even for stored titles whose
// prefix isn't in the normalized `emoji + space` shape, don't rewrite them.
if (emoji === emojiSplit.value.emoji) return;
const newTitle = applySessionEmoji(props.session.title, emoji);
// Never PATCH an empty title (the title was emoji-only and the emoji is removed).
if (newTitle && newTitle !== props.session.title) emit('rename', props.session.id, newTitle);
}
// Inline rename
const renaming = ref(false);
const renameValue = ref('');
@ -135,6 +267,7 @@ const renameInputRef = ref<HTMLInputElement | null>(null);
const { handleCompositionStart, handleCompositionEnd, isComposingKeyEvent } = useImeComposition();
async function startRename(): Promise<void> {
closeMenu();
closePicker();
renaming.value = true;
renameValue.value = props.session.title;
await nextTick();
@ -272,7 +405,14 @@ defineExpose({ closeMenu });
@compositionend="handleCompositionEnd"
@blur="commitRename"
/>
<span v-else class="t" @dblclick.stop="startRename">{{ session.title }}</span>
<span v-else class="t" @dblclick.stop="startRename"><button
v-if="emojiSplit.emoji"
type="button"
class="emoji"
:aria-label="t('sidebar.setEmoji')"
@click.stop="openPickerFromRow"
@dblclick.stop
>{{ emojiSplit.emoji }}</button>{{ displayText }}</span>
</div>
<!-- Pending tags coloured per kind, shown even when the row isn't
@ -361,6 +501,10 @@ defineExpose({ closeMenu });
<Icon name="pencil" size="sm" />
{{ t('sidebar.rename') }}
</MenuItem>
<MenuItem @click="openPickerFromMenu">
<Icon name="emoji" size="sm" />
{{ t('sidebar.setEmoji') }}
</MenuItem>
<MenuItem @click="forkRow">
<Icon name="git-fork" size="sm" />
{{ t('sidebar.fork') }}
@ -382,6 +526,23 @@ defineExpose({ closeMenu });
</Menu>
</Transition>
</Teleport>
<!-- Emoji picker teleported like the kebab menu so the collapsing
`.group-sessions` list's `overflow: hidden` can't clip it. -->
<Teleport to="body">
<Transition name="menu-pop">
<SessionEmojiPicker
v-if="pickerOpen"
ref="pickerRef"
class="picker"
:style="pickerStyle"
:current="emojiSplit.emoji"
:removable="emojiSplit.rest.length > 0"
@click.stop
@pick="applyEmoji"
/>
</Transition>
</Teleport>
</div>
</template>
@ -471,6 +632,17 @@ defineExpose({ closeMenu });
--sb-fade-len: 26px;
}
/* Leading emoji (the session icon): an ordinary title character no
decoration at rest or on hover. It stays a <button> for a11y; the kebab
menu's "Set Emoji…" is the discoverable path. */
.t .emoji {
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.t .emoji:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ts {
color: var(--color-text-faint);
font-size: var(--text-xs);
@ -553,6 +725,14 @@ defineExpose({ closeMenu });
left: 0;
z-index: var(--z-dropdown);
}
/* The emoji picker shares the menu's fixed + teleported placement (anchored by
positionPicker, either to the button or to the title's emoji). */
.picker {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-dropdown);
}
/* Menu enter/exit pops out of the trigger corner (the composer model
dropdown's language): fade + a slight scale, exit a touch faster. The
origin and the nudge direction come from the positioning code. */

View file

@ -107,6 +107,7 @@ import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiCodeLine from '~icons/ri/code-line';
import RiEmotionLine from '~icons/ri/emotion-line';
import RiExternalLinkLine from '~icons/ri/external-link-line';
import RiFileAddLine from '~icons/ri/file-add-line';
import RiFlashlightLine from '~icons/ri/flashlight-line';
@ -194,6 +195,7 @@ import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawCodeLine from '~icons/ri/code-line?raw';
import RawEmotionLine from '~icons/ri/emotion-line?raw';
import RawExternalLinkLine from '~icons/ri/external-link-line?raw';
import RawFileAddLine from '~icons/ri/file-add-line?raw';
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
@ -280,6 +282,7 @@ export type IconName =
| 'clock'
| 'robot'
| 'sparkles'
| 'emoji'
| 'target'
| 'pause'
| 'play'
@ -379,6 +382,7 @@ export const ICONS: Record<IconName, IconEntry> = {
clock: entry(KimiClock, RawKimiClock),
robot: entry(KimiRobot, RawKimiRobot),
sparkles: entry(KimiTask, RawKimiTask),
emoji: entry(RiEmotionLine, RawEmotionLine),
target: entry(KimiTarget, RawKimiTarget),
pause: entry(KimiPause, RawKimiPause),
play: entry(KimiPlay, RawKimiPlay),
@ -492,7 +496,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'microscope',
],
],
['Communication', ['message', 'mail', 'user', 'robot']],
['Communication', ['message', 'mail', 'user', 'robot', 'emoji']],
[
'Status & media',
[

View file

@ -1384,7 +1384,7 @@ onUnmounted(() => {
<tr><td class="tk">no-gradient-text</td><td>gradient text / gradient background</td><td><span class="pill red">Forbidden</span></td></tr>
<tr><td class="tk">no-glassmorphism</td><td><code>backdrop-filter: blur</code> (<b>TopBar sticky nav bar</b> is the sole exception)</td><td><span class="pill amber">TopBar exempt</span></td></tr>
<tr><td class="tk">no-color-glow</td><td>colored / large-radius box-shadow glow</td><td><span class="pill red">Forbidden</span></td></tr>
<tr><td class="tk">no-emoji-icon</td><td>using emoji as a functional icon (<b>the moon phases 🌑🌘 are the sole exception</b>, and only in the "waiting for the Agent to respond" chat state; all other loading states use the plain Spinner)</td><td><span class="pill amber">Moon phase exempt</span></td></tr>
<tr><td class="tk">no-emoji-icon</td><td>using emoji as a functional icon (<b>the moon phases 🌑🌘 are the sole exception</b>, and only in the "waiting for the Agent to respond" chat state; all other loading states use the plain Spinner). Emoji inside <b>user content</b> session titles, messages is not chrome and is out of scope (see §07 Session row's emoji icon)</td><td><span class="pill amber">Moon phase exempt</span></td></tr>
<tr><td class="tk">no-hardcoded-hex</td><td>unregistered hex color inside a component <code>&lt;style&gt;</code></td><td><span class="pill amber">Warning</span></td></tr>
<tr><td class="tk">no-hardcoded-font</td><td>hard-coded <code>font-family</code> in a component (e.g. <code>'Inter'</code>) instead of <code>var(--font-ui)</code></td><td><span class="pill amber">Warning</span></td></tr>
<tr><td class="tk">radius-from-scale</td><td>radius value not in <code>{4,6,8,12,16,20,999}</code></td><td><span class="pill amber">Warning</span></td></tr>
@ -1497,6 +1497,7 @@ onUnmounted(() => {
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> row height is font-driven (title <code>line-height: --leading-tight</code>, 16px) 32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--sb-selected</code> (75% of the global selected wash) neutral, no accent tint, no border, no weight change</td></tr>
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
<tr><td>Title</td><td>flex:1 with truncation and <code>user-select:none</code>; double-click enters inline rename (compact input, not Input), whose text remains selectable</td></tr>
<tr><td>Emoji icon</td><td>the session icon is the title's LEADING emoji cluster (web-core <code>splitSessionEmoji</code> — no icon field; every client renders the title as-is). The emoji is an ordinary title character — no decoration at rest or on hover (it stays a <code>&lt;button&gt;</code> for a11y), and clicking it opens <code>SessionEmojiPicker</code> — a Menu-shelled panel (bare list-style search row → scrollable sections: Recently used persisted in localStorage (cap 8) + the grouped emoji dataset, with remove/random as MenuItems in the footer; a query swaps the sections for keyword-search results), teleported + fixed + <code>--z-dropdown</code>, popping from the trigger corner like the kebab menu. The kebab's "Set Emoji…" opens the same picker and is the touch/keyboard path. Inline rename edits the whole title the emoji is an ordinary character in the input</td></tr>
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
<tr><td>Attention Badge</td><td><code>Badge</code> sm: info (needs answer) / warning (needs approval) / danger (aborted)</td></tr>
<tr><td>kebab</td><td><code>IconButton</code> sm, shown on hover (and pinned visible + lit while its own menu is open); dropdown uses <code>Menu/MenuItem</code>. Right-clicking the row opens the same menu anchored to the cursor without pinning the kebab except over the inline rename input, where the native text-editing menu stays</td></tr>

View file

@ -0,0 +1,241 @@
<!-- apps/web/src/components/SessionEmojiPicker.vue -->
<!-- Session emoji picker popover (SessionRow's "Set Emoji…" entry / title emoji
click): bare search row scrollable sections (Recently used from
localStorage, cap 8 the grouped dataset) MenuItem footer. -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Icon, Menu, MenuItem, useImeComposition } from '@moonshot-ai/web-ui';
import {
EMOJI_ENTRIES,
EMOJI_GROUPS,
pushRecentEmoji,
searchEmojis,
type EmojiGroup,
} from '@moonshot-ai/web-core/lib';
const { t } = useI18n();
// IME guard: Enter that only confirms a composition candidate must not pick.
const { handleCompositionStart, handleCompositionEnd, isComposingKeyEvent } = useImeComposition();
const props = withDefaults(
defineProps<{
current?: string | null;
/** False when the title is emoji-only: removing would leave an empty title. */
removable?: boolean;
}>(),
{ current: null, removable: true },
);
const emit = defineEmits<{ pick: [emoji: string | null] }>();
// Random's pool: a small curated set of safe bets (all round-trip through
// splitSessionEmoji's conservative detection).
const RANDOM_POOL = [
'⏳', '⚠️', '🐛', '✨', '🔥', '🚀', '🎯', '🧪',
'📝', '🔍', '🛠️', '💡', '📦', '🎨', '🔒', '📈',
'🧹', '🚧', '✅', '❓', '🌙', '☕', '🐳', '🗂️',
'📊', '🤖', '🧩', '⚙️', '🌱', '📌', '💥', '🕐',
];
const GROUP_LABEL_KEY: Record<EmojiGroup, string> = {
faces: 'sidebar.emojiGroupFaces',
nature: 'sidebar.emojiGroupNature',
food: 'sidebar.emojiGroupFood',
activity: 'sidebar.emojiGroupActivity',
objects: 'sidebar.emojiGroupObjects',
symbols: 'sidebar.emojiGroupSymbols',
};
const grouped = EMOJI_GROUPS.map((id) => ({
id,
labelKey: GROUP_LABEL_KEY[id],
emojis: EMOJI_ENTRIES.filter((e) => e.group === id).map((e) => e.emoji),
}));
const STORAGE_KEY = 'kimi-web.recent-emojis';
const recents = ref<string[]>(readRecents());
function readRecents(): string[] {
try {
const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
return Array.isArray(raw) ? raw.filter((e): e is string => typeof e === 'string') : [];
} catch {
return [];
}
}
function onPick(emoji: string): void {
recents.value = pushRecentEmoji(recents.value, emoji);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(recents.value));
} catch {
// Quota / private mode: recents just don't persist.
}
emit('pick', emoji);
}
const query = ref('');
const searching = computed(() => query.value.trim().length > 0);
const results = computed(() => searchEmojis(query.value));
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => inputRef.value?.focus());
// Enter picks the first search result.
function onEnter(e: KeyboardEvent): void {
if (isComposingKeyEvent(e)) return;
const first = results.value[0];
if (searching.value && first) onPick(first);
}
function pickRandom(): void {
let next: string | undefined = props.current ?? undefined;
while (next === undefined || next === props.current) {
next = RANDOM_POOL[Math.floor(Math.random() * RANDOM_POOL.length)];
}
onPick(next);
}
// Mirror Menu/IconButton's exposed-el pattern so the consumer can outside-click
// against the panel (positioning is left to the consumer, same as Menu); the
// IME guard is shared for the consumer's Escape handling (Safari reports
// isComposing=false on the candidate-cancelling Escape).
const menuRef = ref<InstanceType<typeof Menu> | null>(null);
defineExpose({ el: computed(() => menuRef.value?.el), isComposingKeyEvent });
</script>
<template>
<Menu ref="menuRef" class="emoji-picker" role="dialog" :aria-label="t('sidebar.sessionEmojiTitle')" @keydown.stop>
<div class="ep-search">
<Icon name="search" size="sm" />
<input
ref="inputRef"
v-model="query"
class="ep-input"
type="text"
:placeholder="t('sidebar.searchEmoji')"
autocomplete="off"
spellcheck="false"
@keydown.enter="onEnter"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
<div class="ep-scroll">
<template v-if="searching">
<div v-if="results.length" class="ep-grid">
<button
v-for="e in results"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
<div v-else class="ep-empty">{{ t('sidebar.noEmojiResults') }}</div>
</template>
<template v-else>
<template v-if="recents.length">
<div class="ep-label">{{ t('sidebar.recentEmojis') }}</div>
<div class="ep-grid">
<button
v-for="e in recents"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
</template>
<template v-for="g in grouped" :key="g.id">
<div class="ep-label">{{ t(g.labelKey) }}</div>
<div class="ep-grid">
<button
v-for="e in g.emojis"
:key="e"
class="ep-e"
:class="{ sel: e === current }"
type="button"
@click="onPick(e)"
>{{ e }}</button>
</div>
</template>
</template>
</div>
<MenuItem separator />
<MenuItem role="button" :disabled="!(current && removable)" @click="emit('pick', null)">
<Icon name="close" size="sm" />
{{ t('sidebar.removeEmoji') }}
</MenuItem>
<MenuItem role="button" @click="pickRandom">
<Icon name="sparkles" size="sm" />
{{ t('sidebar.randomEmoji') }}
</MenuItem>
</Menu>
</template>
<style scoped>
/* Cell size drives every picker metric (matches the IconButton-sm footprint):
8-column grid + derived input height, scroll height and panel width. */
.emoji-picker { --ep-cell: 26px; }
/* Bare list-style search row (the sidebar Search vocabulary): icon + input,
no border; the row shows a sunken wash on hover / focus-within. */
.ep-search {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-1);
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
color: var(--color-text-faint);
}
.ep-search:hover,
.ep-search:focus-within { background: var(--color-surface-sunken); }
.ep-input {
flex: 1;
min-width: 0;
height: calc(var(--ep-cell) + 2px);
font-size: var(--text-sm);
color: var(--color-text);
background: transparent;
border: none;
outline: none;
}
.ep-input::placeholder { color: var(--color-text-faint); }
.ep-scroll { max-height: calc(var(--ep-cell) * 10 + var(--space-1)); overflow-y: auto; padding: 0 var(--space-1); }
/* Section labels — the .side-section-label recipe (xs / 600 / uppercase / faint). */
.ep-label {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-section-label);
text-transform: uppercase;
color: var(--color-text-faint);
user-select: none;
}
.ep-grid { display: grid; grid-template-columns: repeat(8, var(--ep-cell)); gap: var(--space-1); padding-bottom: var(--space-1); }
.ep-e {
height: var(--ep-cell);
display: grid;
place-items: center;
padding: 0;
font-size: var(--text-lg);
background: transparent;
border: none;
border-radius: var(--radius-xs);
cursor: pointer;
}
.ep-e:hover { background: var(--color-hover); }
.ep-e:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ep-e.sel { background: var(--color-accent-soft); }
.ep-empty {
padding: var(--space-3) var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-faint);
text-align: center;
user-select: none;
}
</style>

View file

@ -1,12 +1,15 @@
<!-- apps/web/src/components/SessionRow.vue -->
<!-- A single session row: status dot + title + time + attention pill + kebab. -->
<!-- Inline rename (dblclick) and delete-confirm live here. -->
<!-- Inline rename (dblclick), the emoji icon affordance (hover wash + picker, -->
<!-- see SessionEmojiPicker) and delete-confirm live here. -->
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Session } from '../types';
import { copyTextToClipboard } from '../lib/clipboard';
import { Badge, Icon, IconButton, Menu, MenuItem, Spinner, Tooltip, useImeComposition } from '@moonshot-ai/web-ui';
import { applySessionEmoji, splitSessionEmoji } from '@moonshot-ai/web-core/lib';
import SessionEmojiPicker from './SessionEmojiPicker.vue';
const { t } = useI18n();
@ -109,6 +112,7 @@ async function toggleMenu(e: Event): Promise<void> {
// caller then anchors it the button for toggleMenu, the cursor for
// right-click.
async function openMenu(): Promise<void> {
closePicker();
menuOpen.value = true;
// Defer so the current click doesn't immediately close the menu.
setTimeout(() => document.addEventListener('mousedown', onDocClick), 0);
@ -124,9 +128,137 @@ function closeMenu(): void {
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('mousedown', onPickerDocClick);
window.removeEventListener('keydown', onPickerKeydown, true);
window.removeEventListener('resize', closeMenu);
window.removeEventListener('resize', closePicker);
});
// Emoji picker picking rewrites the title's leading emoji cluster (web-core
// splitSessionEmoji) through the ordinary rename path.
const emojiSplit = computed(() => splitSessionEmoji(props.session.title));
/** Title text after the emoji button — the stored separator + text, byte-for-byte. */
const displayText = computed(() => {
const e = emojiSplit.value.emoji;
return e ? props.session.title.slice(e.length) : props.session.title;
});
const pickerOpen = ref(false);
const pickerRef = ref<InstanceType<typeof SessionEmojiPicker> | null>(null);
const pickerStyle = ref<Record<string, string>>({});
/** Element the picker is anchored to — its own clicks toggle, so outside-click ignores it. */
let pickerAnchor: HTMLElement | null = null;
// Pointer-anchored: transform origin tracks the click even after clamping.
// Keyboard "clicks" (clientX/Y = 0) fall back to the trigger rect's `side` corner.
function positionPicker(r: DOMRect, side: 'left' | 'right', originX?: number): void {
const panel = pickerRef.value?.el;
const gap = 4;
const margin = 8;
const panelH = panel?.offsetHeight ?? 0;
const panelW = panel?.offsetWidth ?? 0;
let top = r.bottom + gap;
let flipped = false;
if (top + panelH > window.innerHeight - margin) {
top = Math.max(margin, r.top - panelH - gap);
flipped = true;
}
const wantLeft = originX ?? (side === 'left' ? r.left : r.right - panelW);
const left = Math.max(margin, Math.min(wantLeft, window.innerWidth - panelW - margin));
const originXPart =
originX === undefined ? side : `${Math.round(Math.min(Math.max(originX - left, 0), panelW))}px`;
pickerStyle.value = {
top: `${Math.round(top)}px`,
left: `${Math.round(left)}px`,
transformOrigin: `${originXPart} ${flipped ? 'bottom' : 'top'}`,
'--menu-pop-shift': flipped ? '2px' : '-2px',
};
}
async function openPicker(
anchor: HTMLElement | undefined,
rect?: DOMRect,
side: 'left' | 'right' = 'left',
originX?: number,
): Promise<void> {
const r = rect ?? anchor?.getBoundingClientRect();
if (!r) return;
if (pickerOpen.value) {
closePicker();
return;
}
closeMenu();
pickerAnchor = anchor ?? null;
pickerOpen.value = true;
// Defer so the opening click doesn't immediately close the panel.
setTimeout(() => document.addEventListener('mousedown', onPickerDocClick), 0);
// Window capture, consumed: the top layer owns Escape ahead of the side
// panel (document capture) and the conversation interrupt (document bubble).
window.addEventListener('keydown', onPickerKeydown, true);
window.addEventListener('resize', closePicker);
// Wait for the teleported panel to mount so its size can be measured.
await nextTick();
positionPicker(r, side, originX);
}
function closePicker(): void {
pickerOpen.value = false;
pickerAnchor = null;
document.removeEventListener('mousedown', onPickerDocClick);
window.removeEventListener('keydown', onPickerKeydown, true);
window.removeEventListener('resize', closePicker);
}
function onPickerDocClick(e: MouseEvent): void {
const target = e.target as Node;
if (pickerRef.value?.el?.contains(target)) return;
// The trigger's own click toggles the picker leave it for openPicker,
// otherwise the mousedown closes and the click immediately reopens.
if (pickerAnchor?.contains(target)) return;
closePicker();
}
function onPickerKeydown(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
// An Escape that only cancels an IME candidate in the search box must not
// close the picker (the picker's own guard covers Safari's isComposing=false).
if (pickerRef.value?.isComposingKeyEvent(e)) return;
e.preventDefault();
e.stopPropagation();
closePicker();
}
function pointRect(e: MouseEvent): DOMRect | undefined {
return e.clientX || e.clientY ? new DOMRect(e.clientX, e.clientY, 0, 0) : undefined;
}
function openPickerFromRow(e: Event): void {
e.stopPropagation();
const me = e as MouseEvent;
void openPicker(me.currentTarget as HTMLElement, pointRect(me), 'left', me.clientX || undefined);
}
function openPickerFromMenu(e: Event): void {
// Right-click opened the menu at the cursor: anchor the picker to the click
// point (or, as a keyboard fallback, the menu's rect captured before
// closeMenu unmounts it), not to the hidden kebab.
const fromCursor = menuAnchor.value === 'cursor';
const anchor = fromCursor ? menuRef.value?.el : kebabRef.value?.el;
const me = e as MouseEvent;
const rect = pointRect(me) ?? anchor?.getBoundingClientRect();
closeMenu();
void openPicker(anchor, rect, fromCursor ? 'left' : 'right', me.clientX || undefined);
}
function applyEmoji(emoji: string | null): void {
closePicker();
// Re-picking the current icon is a no-op even for stored titles whose
// prefix isn't in the normalized `emoji + space` shape, don't rewrite them.
if (emoji === emojiSplit.value.emoji) return;
const newTitle = applySessionEmoji(props.session.title, emoji);
// Never PATCH an empty title (the title was emoji-only and the emoji is removed).
if (newTitle && newTitle !== props.session.title) emit('rename', props.session.id, newTitle);
}
// Inline rename
const renaming = ref(false);
const renameValue = ref('');
@ -135,6 +267,7 @@ const renameInputRef = ref<HTMLInputElement | null>(null);
const { handleCompositionStart, handleCompositionEnd, isComposingKeyEvent } = useImeComposition();
async function startRename(): Promise<void> {
closeMenu();
closePicker();
renaming.value = true;
renameValue.value = props.session.title;
await nextTick();
@ -272,7 +405,14 @@ defineExpose({ closeMenu });
@compositionend="handleCompositionEnd"
@blur="commitRename"
/>
<span v-else class="t" @dblclick.stop="startRename">{{ session.title }}</span>
<span v-else class="t" @dblclick.stop="startRename"><button
v-if="emojiSplit.emoji"
type="button"
class="emoji"
:aria-label="t('sidebar.setEmoji')"
@click.stop="openPickerFromRow"
@dblclick.stop
>{{ emojiSplit.emoji }}</button>{{ displayText }}</span>
</div>
<!-- Pending tags coloured per kind, shown even when the row isn't
@ -361,6 +501,10 @@ defineExpose({ closeMenu });
<Icon name="pencil" size="sm" />
{{ t('sidebar.rename') }}
</MenuItem>
<MenuItem @click="openPickerFromMenu">
<Icon name="emoji" size="sm" />
{{ t('sidebar.setEmoji') }}
</MenuItem>
<MenuItem @click="forkRow">
<Icon name="git-fork" size="sm" />
{{ t('sidebar.fork') }}
@ -382,6 +526,23 @@ defineExpose({ closeMenu });
</Menu>
</Transition>
</Teleport>
<!-- Emoji picker teleported like the kebab menu so the collapsing
`.group-sessions` list's `overflow: hidden` can't clip it. -->
<Teleport to="body">
<Transition name="menu-pop">
<SessionEmojiPicker
v-if="pickerOpen"
ref="pickerRef"
class="picker"
:style="pickerStyle"
:current="emojiSplit.emoji"
:removable="emojiSplit.rest.length > 0"
@click.stop
@pick="applyEmoji"
/>
</Transition>
</Teleport>
</div>
</template>
@ -471,6 +632,17 @@ defineExpose({ closeMenu });
--sb-fade-len: 26px;
}
/* Leading emoji (the session icon): an ordinary title character no
decoration at rest or on hover. It stays a <button> for a11y; the kebab
menu's "Set Emoji…" is the discoverable path. */
.t .emoji {
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.t .emoji:focus-visible { outline: none; box-shadow: var(--p-focus-ring); }
.ts {
color: var(--color-text-faint);
font-size: var(--text-xs);
@ -553,6 +725,14 @@ defineExpose({ closeMenu });
left: 0;
z-index: var(--z-dropdown);
}
/* The emoji picker shares the menu's fixed + teleported placement (anchored by
positionPicker, either to the button or to the title's emoji). */
.picker {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-dropdown);
}
/* Menu enter/exit pops out of the trigger corner (the composer model
dropdown's language): fade + a slight scale, exit a touch faster. The
origin and the nudge direction come from the positioning code. */

View file

@ -105,6 +105,7 @@ import RiCalendarCloseLine from '~icons/ri/calendar-close-line';
import RiCalendarScheduleLine from '~icons/ri/calendar-schedule-line';
import RiCalendarTodoLine from '~icons/ri/calendar-todo-line';
import RiCodeLine from '~icons/ri/code-line';
import RiEmotionLine from '~icons/ri/emotion-line';
import RiExternalLinkLine from '~icons/ri/external-link-line';
import RiFileAddLine from '~icons/ri/file-add-line';
import RiFlashlightLine from '~icons/ri/flashlight-line';
@ -190,6 +191,7 @@ import RawCalendarCloseLine from '~icons/ri/calendar-close-line?raw';
import RawCalendarScheduleLine from '~icons/ri/calendar-schedule-line?raw';
import RawCalendarTodoLine from '~icons/ri/calendar-todo-line?raw';
import RawCodeLine from '~icons/ri/code-line?raw';
import RawEmotionLine from '~icons/ri/emotion-line?raw';
import RawExternalLinkLine from '~icons/ri/external-link-line?raw';
import RawFileAddLine from '~icons/ri/file-add-line?raw';
import RawFlashlightLine from '~icons/ri/flashlight-line?raw';
@ -274,6 +276,7 @@ export type IconName =
| 'clock'
| 'robot'
| 'sparkles'
| 'emoji'
| 'target'
| 'pause'
| 'play'
@ -371,6 +374,7 @@ export const ICONS: Record<IconName, IconEntry> = {
clock: entry(KimiClock, RawKimiClock),
robot: entry(KimiRobot, RawKimiRobot),
sparkles: entry(KimiTask, RawKimiTask),
emoji: entry(RiEmotionLine, RawEmotionLine),
target: entry(KimiTarget, RawKimiTarget),
pause: entry(KimiPause, RawKimiPause),
play: entry(KimiPlay, RawKimiPlay),
@ -482,7 +486,7 @@ export const ICON_GROUPS: ReadonlyArray<readonly [string, readonly IconName[]]>
'microscope',
],
],
['Communication', ['message', 'mail', 'user', 'robot']],
['Communication', ['message', 'mail', 'user', 'robot', 'emoji']],
[
'Status & media',
[

View file

@ -1370,7 +1370,7 @@ onUnmounted(() => {
<tr><td class="tk">no-gradient-text</td><td>gradient text / gradient background</td><td><span class="pill red">Forbidden</span></td></tr>
<tr><td class="tk">no-glassmorphism</td><td><code>backdrop-filter: blur</code> (<b>TopBar sticky nav bar</b> is the sole exception)</td><td><span class="pill amber">TopBar exempt</span></td></tr>
<tr><td class="tk">no-color-glow</td><td>colored / large-radius box-shadow glow</td><td><span class="pill red">Forbidden</span></td></tr>
<tr><td class="tk">no-emoji-icon</td><td>using emoji as a functional icon (<b>the moon phases 🌑🌘 are the sole exception</b>, and only in the "waiting for the Agent to respond" chat state; all other loading states use the plain Spinner)</td><td><span class="pill amber">Moon phase exempt</span></td></tr>
<tr><td class="tk">no-emoji-icon</td><td>using emoji as a functional icon (<b>the moon phases 🌑🌘 are the sole exception</b>, and only in the "waiting for the Agent to respond" chat state; all other loading states use the plain Spinner). Emoji inside <b>user content</b> session titles, messages is not chrome and is out of scope (see §07 Session row's emoji icon)</td><td><span class="pill amber">Moon phase exempt</span></td></tr>
<tr><td class="tk">no-hardcoded-hex</td><td>unregistered hex color inside a component <code>&lt;style&gt;</code></td><td><span class="pill amber">Warning</span></td></tr>
<tr><td class="tk">no-hardcoded-font</td><td>hard-coded <code>font-family</code> in a component (e.g. <code>'Inter'</code>) instead of <code>var(--font-ui)</code></td><td><span class="pill amber">Warning</span></td></tr>
<tr><td class="tk">radius-from-scale</td><td>radius value not in <code>{4,6,8,12,16,20,999}</code></td><td><span class="pill amber">Warning</span></td></tr>
@ -1483,6 +1483,7 @@ onUnmounted(() => {
<tr><td>Container</td><td><code>padding: 8px 8px</code> inside the list's <code>--sb-inset</code> gutter, <code>radius-sm</code>; <b>no fixed/min height</b> row height is font-driven (title <code>line-height: --leading-tight</code>, 16px) 32px total, the sidebar-wide row rhythm. The hover kebab is absolutely positioned so it never forces the row taller (no hover jitter). hover = <code>--sb-hover</code> (the global <code>--color-hover</code> wash); active = <code>--sb-selected</code> (75% of the global selected wash) neutral, no accent tint, no border, no weight change</td></tr>
<tr><td>Status slot (lead)</td><td>fixed <code>--sb-gutter</code> width; running = <code>Spinner</code> sm, otherwise unread = 7px accent dot</td></tr>
<tr><td>Title</td><td>flex:1 with truncation and <code>user-select:none</code>; double-click enters inline rename (compact input, not Input), whose text remains selectable</td></tr>
<tr><td>Emoji icon</td><td>the session icon is the title's LEADING emoji cluster (web-core <code>splitSessionEmoji</code> — no icon field; every client renders the title as-is). The emoji is an ordinary title character — no decoration at rest or on hover (it stays a <code>&lt;button&gt;</code> for a11y), and clicking it opens <code>SessionEmojiPicker</code> — a Menu-shelled panel (bare list-style search row → scrollable sections: Recently used persisted in localStorage (cap 8) + the grouped emoji dataset, with remove/random as MenuItems in the footer; a query swaps the sections for keyword-search results), teleported + fixed + <code>--z-dropdown</code>, popping from the trigger corner like the kebab menu. The kebab's "Set Emoji…" opens the same picker and is the touch/keyboard path. Inline rename edits the whole title the emoji is an ordinary character in the input</td></tr>
<tr><td>Time</td><td>mono xs, <code>fg-faint</code>; yields to the kebab on hover</td></tr>
<tr><td>Attention Badge</td><td><code>Badge</code> sm: info (needs answer) / warning (needs approval) / danger (aborted)</td></tr>
<tr><td>kebab</td><td><code>IconButton</code> sm, shown on hover (and pinned visible + lit while its own menu is open); dropdown uses <code>Menu/MenuItem</code>. Right-clicking the row opens the same menu anchored to the cursor without pinning the kebab except over the inline rename input, where the native text-editing menu stays</td></tr>

View file

@ -0,0 +1,690 @@
<!DOCTYPE html>
<html lang="zh-CN" data-color-scheme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Emoji · 侧栏「hover 虚线框选 emoji」方案原型</title>
<style>
/* ===========================================================================
Tokens —— 与 packages/web-ui/src/style.css 的 token 层一一对应。
字体在真实应用中是自托管 Schibsted Grotesk + Noto Sans SC Variable
原型退回系统字体栈(仅影响字腔,不影响节奏/颜色/层级)。
=========================================================================== */
:root {
--color-bg: #ffffff;
--color-surface: #fafbfc;
--color-surface-raised: #ffffff;
--color-surface-overlay: #ffffff;
--color-surface-sunken: #f3f5f8;
--color-well: #f3f5f8;
--color-sidebar-bg: #fbfaf9;
--color-text: rgba(0, 0, 0, 0.9);
--color-text-muted: #6b7280;
--color-text-faint: #9aa3af;
--color-line: #e7eaee;
--color-line-strong: #d4d9e0;
--color-selected: #00000014;
--color-hover: #0000000d;
--color-accent: #1783ff;
--color-accent-hover: #0f6fe0;
--color-accent-soft: #e8f3ff;
--color-accent-bd: #cfe6ff;
--color-warning: #a9610a;
--color-warning-soft: #fbf1e0;
--color-warning-bd: #f0d9b8;
--color-danger: #c0392b;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 24px; --space-8: 32px;
--radius-xs: 4px; --radius-sm: 6px; --radius-md: 8px; --radius-lg: 12px;
--radius-xl: 16px; --radius-full: 999px;
--shadow-menu: 0 6px 18px lch(0% 0 0 / 0.02), 0 3px 9px lch(0% 0 0 / 0.04), 0 1px 1px lch(0% 0 0 / 0.04);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-base: 160ms;
--text-xs: 12px; --text-sm: 13px; --text-base: 14px; --text-lg: 16px;
--leading-tight: 1.25; --leading-normal: 1.5; --leading-prose: 1.6;
--weight-regular: 400; --weight-medium: 500;
--font-ui: "Schibsted Grotesk Variable", "Helvetica Neue", Arial,
"Noto Sans SC Variable", "Noto Sans SC", "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace,
"SF Mono", Menlo, Consolas, monospace;
--p-content-max: 860px;
}
html[data-color-scheme="dark"] {
color-scheme: dark;
--color-bg: #0d1117;
--color-surface: #13181e;
--color-surface-raised: #1c2128;
--color-surface-overlay: #22272e;
--color-surface-sunken: #0d1117;
--color-well: #13181e;
--color-sidebar-bg: #0a0d12;
--color-text: #e8eaed;
--color-text-muted: #9aa0a8;
--color-text-faint: #6b7280;
--color-line: #2d333b;
--color-line-strong: #3d444d;
--color-selected: #ffffff14;
--color-hover: #ffffff0d;
--color-accent: #58a6ff;
--color-accent-hover: #79b8ff;
--color-accent-soft: rgba(88, 166, 255, 0.14);
--color-accent-bd: rgba(88, 166, 255, 0.28);
--color-warning: #d29922;
--color-warning-soft: rgba(210, 153, 34, 0.14);
--color-warning-bd: rgba(210, 153, 34, 0.28);
--color-danger: #f85149;
--shadow-menu: 0 6px 18px rgba(0, 0, 0, 0.2), 0 3px 9px rgba(0, 0, 0, 0.24), 0 1px 1px rgba(0, 0, 0, 0.24);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: var(--font-ui);
font-size: var(--text-base);
line-height: var(--leading-prose);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; background: none; border: none; padding: 0; cursor: pointer; }
code { font-family: var(--font-mono); font-size: var(--text-sm); background: var(--color-surface-sunken); border-radius: var(--radius-xs); padding: 1px 5px; }
html[data-color-scheme="dark"] code { background: #ffffff1a; }
::selection { background: rgba(23, 131, 255, 0.18); }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}
/* ============================= 顶栏 ============================= */
.topbar {
position: sticky; top: 0; z-index: 100;
display: flex; align-items: center; gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--color-bg); border-bottom: 0.5px solid var(--color-line);
user-select: none;
}
.topbar .brand { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-right: var(--space-3); }
.topbar .sp { flex: 1; }
.demo-btn {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 var(--space-3);
border-radius: var(--radius-full); border: 0.5px solid var(--color-line-strong);
background: var(--color-surface-raised); font-size: var(--text-xs); color: var(--color-text-muted);
transition: color var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out);
}
.demo-btn:hover { color: var(--color-text); border-color: var(--color-text-faint); }
.demo-btn.on { color: var(--color-accent); border-color: var(--color-accent-bd); background: var(--color-accent-soft); }
/* ============================= 页面骨架 ============================= */
.page { display: none; }
.page.on { display: block; }
.wrap { max-width: var(--p-content-max); margin: 0 auto; padding: var(--space-6) var(--space-6) 120px; }
.wrap h1 { font-size: 20px; font-weight: 600; margin: 0 0 var(--space-1); }
.wrap .sub { font-size: var(--text-sm); color: var(--color-text-faint); margin: 0 0 var(--space-5); }
.wrap h2 {
font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--color-text-faint);
margin: var(--space-8) 0 var(--space-2); text-transform: none;
}
.wrap h2 .n { font-family: var(--font-mono); color: var(--color-accent); margin-right: 6px; }
.wrap p, .wrap li { color: var(--color-text-muted); font-size: var(--text-sm); }
.wrap p b, .wrap li b { color: var(--color-text); font-weight: var(--weight-medium); }
.wrap ul { margin: 0 0 var(--space-3); padding-left: 20px; }
.wrap li { margin-bottom: 4px; }
.pro { color: var(--color-accent); font-weight: var(--weight-medium); }
.con { color: var(--color-danger); font-weight: var(--weight-medium); }
.demo-stage {
border: 0.5px dashed var(--color-line-strong); border-radius: var(--radius-lg);
padding: var(--space-5); display: flex; gap: var(--space-6); align-items: flex-start;
background: var(--color-surface);
}
.demo-hint { font-size: var(--text-xs); color: var(--color-text-faint); margin: 6px 0 0; }
/* ============================= 侧栏 mock还原截图语境 ============================= */
.side {
width: 264px; flex: none; background: var(--color-sidebar-bg);
border: 0.5px solid var(--color-line); border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-2); user-select: none;
}
.ws-card {
display: flex; align-items: center; gap: var(--space-2);
background: var(--color-surface-sunken); border-radius: var(--radius-md);
padding: 7px var(--space-2); margin-bottom: var(--space-2);
font-size: var(--text-sm); font-weight: var(--weight-medium);
}
.ws-card svg { flex: none; color: var(--color-text-muted); }
html[data-color-scheme="dark"] .ws-card { background: var(--color-well); }
/* 行基础 —— 三个方案共用骨架(对齐 SessionRow.vue8px padding / 13px 标题 / 右侧时间) */
.se {
display: flex; align-items: center; gap: 6px;
padding: 5px var(--space-2); border-radius: var(--radius-sm);
font-size: var(--text-sm); color: var(--color-text-muted);
cursor: pointer; position: relative;
}
.se:hover, .se.fh { background: var(--color-hover); color: var(--color-text); }
.se.active { background: var(--color-selected); color: var(--color-text); }
.se .st { width: 14px; flex: none; display: inline-flex; align-items: center; justify-content: center; }
.se .t {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: var(--text-sm); line-height: var(--leading-tight);
}
.se .tm { flex: none; font-size: var(--text-xs); color: var(--color-text-faint); font-variant-numeric: tabular-nums; }
.spin {
width: 10px; height: 10px; border-radius: 50%;
border: 1.5px solid var(--color-line-strong); border-top-color: var(--color-text-muted);
animation: rot 0.8s linear infinite;
}
@keyframes rot { to { transform: rotate(360deg); } }
/* ===========================================================================
方案 A —— 独立 emoji 槽emoji 提升为 session 元数据,槽位常驻宽度)
=========================================================================== */
.pa .es {
width: 18px; height: 18px; flex: none;
display: grid; place-items: center;
border: 1px dashed transparent; border-radius: var(--radius-xs);
font-size: 13px; line-height: 1;
transition: border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.pa .es .ph { font-size: 11px; color: var(--color-text-faint); opacity: 0; transition: opacity var(--duration-fast); }
.pa .es .em { display: none; }
.pa .es.has .em { display: block; }
.pa .es.has .ph { display: none; }
/* hover 行:空槽显虚线框 + 淡 +;有 emoji 的槽外套虚线圈 */
.pa .se:hover .es, .pa .se.fh .es { border-color: var(--color-line-strong); }
.pa .se:hover .es:not(.has) .ph, .pa .se.fh .es:not(.has) .ph { opacity: 1; }
/* 直接 hover 槽位accent 反馈,表达「可点」 */
.pa .es:hover { border-color: var(--color-accent) !important; background: var(--color-accent-soft); }
.pa .es:hover .ph { color: var(--color-accent); opacity: 1 !important; }
.pa .es:focus-visible { outline: 1px solid var(--color-accent); outline-offset: 1px; }
/* ===========================================================================
方案 B —— 标题内联emoji 留在标题字符串hover 虚线圈出首字符 / 覆盖式加号框)
=========================================================================== */
.pb .t .e { border-radius: var(--radius-xs); cursor: pointer; }
.pb .se:hover .t .e, .pb .se.fh .t .e { outline: 1px dashed var(--color-line-strong); outline-offset: 2px; }
.pb .t .e:hover { outline: 1px dashed var(--color-accent) !important; background: var(--color-accent-soft); }
/* 无 emoji 行hover 时虚线框覆盖在标题起始处(不占位,会遮住首字符 —— 如实呈现) */
.pb .ov {
position: absolute; left: 28px; top: 50%; transform: translateY(-50%);
width: 18px; height: 18px; display: none; place-items: center;
border: 1px dashed var(--color-line-strong); border-radius: var(--radius-xs);
background: var(--color-sidebar-bg);
font-size: 11px; color: var(--color-text-faint); cursor: pointer; z-index: 2;
}
.pb .se:hover .ov, .pb .se.fh .ov { display: grid; }
.pb .ov:hover { border-color: var(--color-accent); color: var(--color-accent); background: var(--color-accent-soft); }
/* ===========================================================================
方案 C —— 菜单入口 + 轻提示行上无新增常驻元素kebab 菜单收「设置 Emoji…」
=========================================================================== */
.pc .t .e { border-radius: var(--radius-xs); cursor: pointer; }
.pc .se:hover .t .e, .pc .se.fh .t .e { outline: 1px dashed var(--color-line-strong); outline-offset: 2px; }
.pc .t .e:hover { outline: 1px dashed var(--color-accent) !important; background: var(--color-accent-soft); }
/* 尾槽:时间与 kebab 互换visibility不换宽度 —— 沿用 §07 session row 语言) */
.pc .act { position: relative; flex: none; display: inline-flex; align-items: center; min-width: 22px; justify-content: flex-end; }
.pc .kb {
position: absolute; right: -4px; top: 50%; transform: translateY(-50%);
width: 22px; height: 22px; display: grid; place-items: center;
border-radius: var(--radius-sm); color: var(--color-text-faint);
visibility: hidden; font-size: 14px; letter-spacing: 1px;
background: var(--color-sidebar-bg);
}
.pc .se:hover .kb, .pc .se.fh .kb { visibility: visible; }
.pc .se:hover .kb ~ .tm, .pc .se.fh .kb ~ .tm { visibility: hidden; }
.pc .kb:hover { color: var(--color-text); background: var(--color-hover); }
/* ============================= Emoji picker三方案共用 ============================= */
.picker {
position: fixed; z-index: 300; display: none;
width: 240px; padding: var(--space-2);
background: var(--color-surface-overlay); border: 0.5px solid var(--color-line);
border-radius: var(--radius-md); box-shadow: var(--shadow-menu);
}
.pk-label { font-size: var(--text-xs); color: var(--color-text-faint); padding: 2px 6px 6px; }
.pk-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px; }
.pk-e {
height: 26px; display: grid; place-items: center; font-size: 15px;
border-radius: var(--radius-xs); cursor: pointer;
}
.pk-e:hover { background: var(--color-hover); }
.pk-e.sel { background: var(--color-accent-soft); outline: 1px solid var(--color-accent-bd); }
.pk-foot { display: flex; gap: 2px; margin-top: 6px; padding-top: 6px; border-top: 0.5px solid var(--color-line); }
.pk-act {
flex: 1; height: 24px; display: inline-flex; align-items: center; justify-content: center; gap: 4px;
font-size: var(--text-xs); color: var(--color-text-muted); border-radius: var(--radius-xs); cursor: pointer;
}
.pk-act:hover { background: var(--color-hover); color: var(--color-text); }
.pk-act.danger:hover { color: var(--color-danger); }
/* 静态陈列里的 picker 样张 */
.pk-sample { position: static; display: block; width: 240px; }
/* ============================= 方案 C 的 kebab 菜单 ============================= */
.menu {
position: fixed; z-index: 310; display: none; min-width: 168px; padding: 4px;
background: var(--color-surface-overlay); border: 0.5px solid var(--color-line);
border-radius: var(--radius-md); box-shadow: var(--shadow-menu);
font-size: var(--text-sm);
}
.mi {
display: flex; align-items: center; gap: var(--space-2); width: 100%;
padding: 5px var(--space-2); border-radius: var(--radius-xs);
color: var(--color-text); cursor: pointer; text-align: left;
}
.mi:hover { background: var(--color-hover); }
.mi svg { flex: none; color: var(--color-text-muted); }
.mi-sep { height: 1px; margin: 4px 6px; background: var(--color-line); }
/* ============================= 状态陈列 ============================= */
.gal-row { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-3); }
.gal-tag { width: 148px; flex: none; font-size: var(--text-xs); color: var(--color-text-faint); text-align: right; }
.gal-row .side { width: 264px; }
/* ============================= 对比表 ============================= */
table.cmp { width: 100%; border-collapse: collapse; margin: var(--space-2) 0 var(--space-4); font-size: var(--text-sm); }
.cmp th, .cmp td { text-align: left; padding: 8px 12px; border-bottom: 0.5px solid var(--color-line); vertical-align: top; }
.cmp th { color: var(--color-text-faint); font-weight: var(--weight-medium); font-size: var(--text-xs); }
.cmp td { color: var(--color-text-muted); }
.cmp td:first-child { color: var(--color-text); font-weight: var(--weight-medium); white-space: nowrap; }
.callout {
border: 0.5px solid var(--color-warning-bd); background: var(--color-warning-soft);
border-radius: var(--radius-md); padding: var(--space-3) var(--space-4);
font-size: var(--text-sm); color: var(--color-text-muted); margin: var(--space-4) 0;
}
.callout b { color: var(--color-text); }
</style>
</head>
<body>
<header class="topbar">
<span class="brand">Session Emoji · hover 虚线框方案原型</span>
<button class="demo-btn on" data-page="a">方案 A · 独立图标槽</button>
<button class="demo-btn" data-page="b">方案 B · 标题内联圈选</button>
<button class="demo-btn" data-page="c">方案 C · 菜单入口 + 轻提示</button>
<button class="demo-btn" data-page="cmp">对比与建议</button>
<span class="sp"></span>
<button class="demo-btn" id="btn-theme">◐ 主题</button>
</header>
<!-- ═══════════════════════════ 方案 A ═══════════════════════════ -->
<section class="page on" id="page-a">
<div class="wrap">
<h1>方案 A · 独立图标槽</h1>
<p class="sub">emoji 提升为 session 的元数据(不再混在标题字符串里),行首常驻一个 18px 槽位hover 时空槽显虚线框、已有 emoji 外套虚线圈,点击开 picker。</p>
<h2><span class="n">A1</span>交互 demo —— hover 行看虚线框,点槽位换 emoji</h2>
<div class="demo-stage">
<div class="side pa" id="demo-a">
<div class="ws-card">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg>
kimi-code-app2
</div>
<div class="se" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳">
<span class="st"></span>
<button class="es" aria-label="设置 Emoji"><span class="ph">+</span><span class="em"></span></button>
<span class="t">做一个 desktop 的需求,希望 …</span>
<span class="tm">1h</span>
</div>
<div class="se" data-title="排查一个 desktop 的 bug现 …" data-emoji="⚠️">
<span class="st"></span>
<button class="es" aria-label="设置 Emoji"><span class="ph">+</span><span class="em"></span></button>
<span class="t">排查一个 desktop 的 bug现 …</span>
<span class="tm">7h</span>
</div>
<div class="se" data-title="release: v0.3.2 changeset" data-emoji="">
<span class="st"><span class="spin"></span></span>
<button class="es" aria-label="设置 Emoji"><span class="ph">+</span><span class="em"></span></button>
<span class="t">release: v0.3.2 changeset</span>
<span class="tm">12m</span>
</div>
<div class="se" data-title="composer 工作区选择器" data-emoji="">
<span class="st"></span>
<button class="es" aria-label="设置 Emoji"><span class="ph">+</span><span class="em"></span></button>
<span class="t">composer 工作区选择器</span>
<span class="tm">昨天</span>
</div>
</div>
<div>
<p style="margin-top:0"><b>试一试</b>hover 任意行 → 行首出现虚线框(已有 emoji 的行是虚线圈);点击槽位打开 picker选一个 emoji 或「移除」。第三行演示 busy 状态spinner 占用更左的状态 gutter与 emoji 槽并存)。</p>
<p class="demo-hint">槽位是 <code>&lt;button&gt;</code>,可 Tab 聚焦、Enter 打开 pickerEsc 关闭。</p>
</div>
</div>
<h2><span class="n">A2</span>状态陈列</h2>
<div class="gal-row"><span class="gal-tag">静止 · 无 emoji</span>
<div class="side pa"><div class="se" data-title="composer 工作区选择器" data-emoji=""><span class="st"></span><button class="es"><span class="ph">+</span><span class="em"></span></button><span class="t">composer 工作区选择器</span><span class="tm">昨天</span></div></div>
<span class="demo-hint">完全干净,与现状一致</span>
</div>
<div class="gal-row"><span class="gal-tag">hover · 无 emoji</span>
<div class="side pa"><div class="se fh" data-title="composer 工作区选择器" data-emoji=""><span class="st"></span><button class="es"><span class="ph">+</span><span class="em"></span></button><span class="t">composer 工作区选择器</span><span class="tm">昨天</span></div></div>
<span class="demo-hint">虚线框 + 淡「+」,只在 hover 出现</span>
</div>
<div class="gal-row"><span class="gal-tag">静止 · 有 emoji</span>
<div class="side pa"><div class="se" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳"><span class="st"></span><button class="es"><span class="ph">+</span><span class="em"></span></button><span class="t">做一个 desktop 的需求,希望 …</span><span class="tm">1h</span></div></div>
<span class="demo-hint">emoji 常显,标题起始位置全列表对齐</span>
</div>
<div class="gal-row"><span class="gal-tag">hover · 有 emoji</span>
<div class="side pa"><div class="se fh" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳"><span class="st"></span><button class="es"><span class="ph">+</span><span class="em"></span></button><span class="t">做一个 desktop 的需求,希望 …</span><span class="tm">1h</span></div></div>
<span class="demo-hint">虚线圈提示「可换」</span>
</div>
<div class="gal-row"><span class="gal-tag">picker 展开</span>
<div class="picker pk-sample">
<div class="pk-label">设置 Session Emoji</div>
<div class="pk-grid pk-grid-sample"></div>
<div class="pk-foot"><button class="pk-act danger">移除 Emoji</button><button class="pk-act">随机一个</button></div>
</div>
<span class="demo-hint">精选 32 个常用 emoji8×4锚定槽位下方空间不足上翻</span>
</div>
<h2><span class="n">A3</span>优劣</h2>
<ul>
<li><span class="pro"></span> <b>布局绝对稳定</b>:槽位常驻 18px标题起始 x 全列表对齐hover 零位移 —— 与 §07「session row 不因 hover reflow」的原则一致。</li>
<li><span class="pro"></span> <b>语义干净</b>emoji 是结构化字段rename、搜索、导出都不受标题里 emoji 的干扰;自动标题生成后可把 emoji 前缀剥离进该字段。</li>
<li><span class="pro"></span> 发现性好hover 即见虚线框,且静止时完全无噪声。</li>
<li><span class="con"></span> <b>改动面最大</b>session 需要新增 icon 字段server 存储 + 多端同步策略),行布局多一个槽。</li>
<li><span class="con"></span> 行首水平空间更紧张:状态 gutterspinner/unread+ emoji 槽 + 标题,窄侧栏下标题更短。</li>
</ul>
</div>
</section>
<!-- ═══════════════════════════ 方案 B ═══════════════════════════ -->
<section class="page" id="page-b">
<div class="wrap">
<h1>方案 B · 标题内联圈选</h1>
<p class="sub">emoji 留在标题字符串里(现状如此:自动标题自带 emoji 前缀hover 时用虚线圈出标题首字符的 emoji 供点击更换,无 emoji 的行在标题起始处覆盖一个虚线加号框。</p>
<h2><span class="n">B1</span>交互 demo</h2>
<div class="demo-stage">
<div class="side pb" id="demo-b">
<div class="ws-card">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg>
kimi-code-app2
</div>
<div class="se" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳"><span class="st"></span><span class="t"></span><span class="tm">1h</span></div>
<div class="se" data-title="排查一个 desktop 的 bug现 …" data-emoji="⚠️"><span class="st"></span><span class="t"></span><span class="tm">7h</span></div>
<div class="se" data-title="release: v0.3.2 changeset" data-emoji=""><span class="st"><span class="spin"></span></span><span class="t"></span><span class="tm">12m</span><span class="ov">+</span></div>
<div class="se" data-title="composer 工作区选择器" data-emoji=""><span class="st"></span><span class="t"></span><span class="tm">昨天</span><span class="ov">+</span></div>
</div>
<div>
<p style="margin-top:0"><b>试一试</b>hover 有 emoji 的行 → 标题首字符被虚线圈出点击更换hover 无 emoji 的行 → 标题起始处覆盖一个虚线「+」框(<b>注意它会遮住标题第一个字</b>,这是该方案的真实代价)。</p>
<p class="demo-hint">零数据模型改动picker 写入的就是标题字符串的 emoji 前缀,存量 session 天然兼容。</p>
</div>
</div>
<h2><span class="n">B2</span>状态陈列</h2>
<div class="gal-row"><span class="gal-tag">hover · 有 emoji</span>
<div class="side pb"><div class="se fh" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳"><span class="st"></span><span class="t"></span><span class="tm">1h</span></div></div>
<span class="demo-hint">虚线圈用 <code>outline: dashed</code>,不占布局</span>
</div>
<div class="gal-row"><span class="gal-tag">hover · 无 emoji</span>
<div class="side pb"><div class="se fh" data-title="composer 工作区选择器" data-emoji=""><span class="st"></span><span class="t"></span><span class="tm">昨天</span><span class="ov">+</span></div></div>
<span class="demo-hint">覆盖式虚线框,遮挡标题首字符</span>
</div>
<h2><span class="n">B3</span>优劣</h2>
<ul>
<li><span class="pro"></span> <b>实现成本最低</b>纯前端改动不动数据模型、不动行布局存量「emoji + 标题」的 session 直接可用。</li>
<li><span class="pro"></span> 符合直觉:用户看到的就是标题的一部分,「改它」的心理模型直接。</li>
<li><span class="con"></span> <b>标题即数据的耦合风险</b>:识别「标题首字符是不是 emoji」需要 grapheme 级解析ZWJ 序列、修饰符);与双击 rename 的起点选择、标题搜索、排序都产生耦合。</li>
<li><span class="con"></span> <b>布局不齐</b>:有 emoji 的行标题整体右移,行间标题起始 x 不对齐;无 emoji 行的 hover 框遮挡首字符。</li>
<li><span class="con"></span> 自动标题重新生成时会覆盖用户手动选的 emoji除非额外记录</li>
</ul>
</div>
</section>
<!-- ═══════════════════════════ 方案 C ═══════════════════════════ -->
<section class="page" id="page-c">
<div class="wrap">
<h1>方案 C · 菜单入口 + 轻提示</h1>
<p class="sub">行上不新增任何常驻交互元素kebab/右键菜单收一个「设置 Emoji…」入口。已设 emoji 的行 hover 时给 emoji 一个虚线圈轻提示,点击也可直接唤起 picker。</p>
<h2><span class="n">C1</span>交互 demo</h2>
<div class="demo-stage">
<div class="side pc" id="demo-c">
<div class="ws-card">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z"/></svg>
kimi-code-app2
</div>
<div class="se" data-title="做一个 desktop 的需求,希望 …" data-emoji="⏳"><span class="st"></span><span class="t"></span><span class="act"><button class="kb" aria-label="更多操作"></button><span class="tm">1h</span></span></div>
<div class="se" data-title="排查一个 desktop 的 bug现 …" data-emoji="⚠️"><span class="st"></span><span class="t"></span><span class="act"><button class="kb" aria-label="更多操作"></button><span class="tm">7h</span></span></div>
<div class="se" data-title="release: v0.3.2 changeset" data-emoji=""><span class="st"><span class="spin"></span></span><span class="t"></span><span class="act"><button class="kb" aria-label="更多操作"></button><span class="tm">12m</span></span></div>
<div class="se" data-title="composer 工作区选择器" data-emoji=""><span class="st"></span><span class="t"></span><span class="act"><button class="kb" aria-label="更多操作"></button><span class="tm">昨天</span></span></div>
</div>
<div>
<p style="margin-top:0"><b>试一试</b>hover 行 → 右侧出现 kebab「⋯」点开菜单选「设置 Emoji…」有 emoji 的行 hover 时 emoji 会被虚线圈出,点击它也能直接换。</p>
<p class="demo-hint">无 emoji 的行在静止与 hover 下都与现状完全一致 —— 零新增视觉元素。</p>
</div>
</div>
<h2><span class="n">C2</span>优劣</h2>
<ul>
<li><span class="pro"></span> <b>最克制</b>:不给列表增加任何常驻/悬停视觉负担,与现有 kebab 词汇完全同构(重命名、归档的同款路径)。</li>
<li><span class="pro"></span> <b>触屏可达</b>:不依赖 hover —— 移动端/触屏唯一的可用入口;键盘走同样的菜单路径。</li>
<li><span class="con"></span> <b>发现性最低</b>:功能藏在二级菜单里,用户大概率不知道可以设 emoji。</li>
</ul>
<div class="callout"><b>注意</b>:无论最终选 A 还是 BC 的菜单入口都建议作为<b>必备兜底</b>一并实现 —— hover 在触屏上不存在,键盘用户也需要一个不依赖指针悬停的可达路径。</div>
</div>
</section>
<!-- ═══════════════════════════ 对比与建议 ═══════════════════════════ -->
<section class="page" id="page-cmp">
<div class="wrap">
<h1>对比与建议</h1>
<p class="sub">三个方案不是互斥的全集 —— 推荐组合落地,详见文末。</p>
<h2><span class="n">T1</span>对比表</h2>
<table class="cmp">
<tr><th style="width:120px">维度</th><th>A · 独立图标槽</th><th>B · 标题内联圈选</th><th>C · 菜单入口</th></tr>
<tr><td>发现性</td><td>hover 即见虚线框</td><td>中:有 emoji 才直观</td><td>低:藏在菜单里</td></tr>
<tr><td>视觉噪声(静止)</td><td></td><td></td><td></td></tr>
<tr><td>布局稳定性</td><td>标题起始 x 全列表对齐hover 零位移</td><td>有 emoji 的行标题右移不对齐;无 emoji 行 hover 框遮挡首字符</td><td>同 Bemoji 在标题内时)</td></tr>
<tr><td>数据模型</td><td>session 新增 icon 字段server 存储 + 同步策略)</td><td>零改动emoji 留在标题字符串</td><td>取决于选 A 还是 B 的存储</td></tr>
<tr><td>存量/自动标题兼容</td><td>需迁移:剥离自动标题的 emoji 前缀写入字段</td><td>天然兼容(现状即如此)</td><td></td></tr>
<tr><td>触屏 / 键盘</td><td>槽位是 button 可 Tab触屏无 hover 需 C 兜底</td><td>同左</td><td>菜单路径天然可达</td></tr>
<tr><td>实现成本</td><td>中(布局 + 字段 + 迁移)</td><td>低(纯前端)</td><td></td></tr>
<tr><td>主要风险</td><td>行首空间与状态 gutter 争抢</td><td>grapheme 解析、rename/搜索耦合、自动标题覆盖手动选择</td><td>功能被埋没</td></tr>
</table>
<h2><span class="n">T2</span>建议</h2>
<ul>
<li><b>主方案选 A</b>emoji 作为 session 元数据、独立槽位常显hover 虚线框作为唯一新增交互。布局稳定、语义干净,长期债最少。</li>
<li><b>C 的菜单入口必备</b>:作为触屏与键盘的可达性兜底,与 A 共用同一个 picker。</li>
<li><b>B 可作零成本过渡</b>:如果 server 字段短期排不上期,先用 B 纯前端落地picker 直接改写标题前缀),但接受 grapheme 解析与 rename 耦合的风险A 上线后退役。</li>
</ul>
<h2><span class="n">T3</span>开放问题(实现前需定)</h2>
<ul>
<li><b>存储</b>icon 字段存 server随 session 同步多设备)还是本地?外部 server 模式下行为是否一致?</li>
<li><b>迁移</b>存量「emoji + 标题」session 是自动剥离,还是保留双显由用户手动清理?自动标题重生成时是否保留用户手选的 emoji</li>
<li><b>picker 选集</b>:固定精选 32 个(当前原型)/ 分组全量 + 搜索 / 直接唤起系统 emoji 键盘?</li>
<li><b>状态优先级</b>busyspinner/ unread蓝点与 emoji 槽并存时的层级 —— 原型按「spinner 在更左的 gutteremoji 槽不让位」演示,需确认。</li>
<li><b>设计系统</b>§icon 的 no-emoji-icon 规则约束的是 UI chromesession emoji 属于用户内容(同标题文本),建议在该节补一句豁免说明,避免后续误读。</li>
</ul>
</div>
</section>
<!-- 共享浮层picker + kebab 菜单 -->
<div class="picker" id="picker">
<div class="pk-label">设置 Session Emoji</div>
<div class="pk-grid" id="pk-grid"></div>
<div class="pk-foot">
<button class="pk-act danger" id="pk-remove">移除 Emoji</button>
<button class="pk-act" id="pk-random">随机一个</button>
</div>
</div>
<div class="menu" id="menu">
<button class="mi" data-act="emoji">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5s1 1.5 3.5 1.5 3.5-1.5 3.5-1.5"/><circle cx="9" cy="10" r="0.5" fill="currentColor"/><circle cx="15" cy="10" r="0.5" fill="currentColor"/></svg>
设置 Emoji…
</button>
<button class="mi" data-act="rename">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z"/></svg>
重命名
</button>
<div class="mi-sep"></div>
<button class="mi" data-act="archive">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10a1 1 0 001 1h12a1 1 0 001-1V9M10 13h4"/></svg>
归档
</button>
</div>
<script>
/* ============================= 主题 / 页面切换 ============================= */
document.getElementById('btn-theme').addEventListener('click', () => {
const el = document.documentElement;
el.dataset.colorScheme = el.dataset.colorScheme === 'light' ? 'dark' : 'light';
});
document.querySelectorAll('.topbar .demo-btn[data-page]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.topbar .demo-btn[data-page]').forEach(b => b.classList.toggle('on', b === btn));
document.querySelectorAll('.page').forEach(p => p.classList.toggle('on', p.id === 'page-' + btn.dataset.page));
closePicker(); closeMenu();
});
});
/* ============================= 行渲染 ============================= */
/* data-emoji 是唯一状态源;标题文本固定在 data-titleemoji 渲染进各自方案的容器 */
function renderRow(se) {
const e = se.dataset.emoji || '';
const title = se.dataset.title || '';
const slot = se.querySelector('.es'); // 方案 A 的槽位
if (slot) {
slot.classList.toggle('has', !!e);
slot.querySelector('.em').textContent = e;
}
const t = se.querySelector('.t');
if (t && !slot) { // 方案 B / Cemoji 内联进标题
t.textContent = '';
if (e) {
const s = document.createElement('span');
s.className = 'e'; s.textContent = e; s.tabIndex = 0;
s.setAttribute('role', 'button'); s.setAttribute('aria-label', '更换 Emoji');
t.appendChild(s);
t.appendChild(document.createTextNode(' '));
}
t.appendChild(document.createTextNode(title));
}
}
document.querySelectorAll('.se[data-title]').forEach(renderRow);
/* ============================= Emoji picker ============================= */
const EMOJIS = ['⏳','⚠️','🐛','✨','🔥','🚀','🎯','🧪','📝','🔍','🛠️','💡','📦','🎨','🔒','📈',
'🧹','🚧','✅','❓','🌙','☕','🐳','🗂️','📊','🤖','🧩','⚙️','🌱','📌','💥','🕐'];
const picker = document.getElementById('picker');
const pkGrid = document.getElementById('pk-grid');
const pkRemove = document.getElementById('pk-remove');
let applyPick = null;
function buildGrid(container) {
EMOJIS.forEach(e => {
const b = document.createElement('button');
b.className = 'pk-e'; b.textContent = e;
container.appendChild(b);
});
}
buildGrid(pkGrid);
buildGrid(document.querySelector('.pk-grid-sample'));
function openPicker(anchor, current, apply) {
applyPick = apply;
pkGrid.querySelectorAll('.pk-e').forEach(b => b.classList.toggle('sel', b.textContent === current));
pkRemove.style.display = current ? '' : 'none';
picker.style.display = 'block';
const r = anchor.getBoundingClientRect();
const pw = picker.offsetWidth, ph = picker.offsetHeight;
let top = r.bottom + 6;
if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 6);
const left = Math.min(Math.max(8, r.left), window.innerWidth - pw - 8);
picker.style.top = top + 'px';
picker.style.left = left + 'px';
setTimeout(() => document.addEventListener('mousedown', onDocDown), 0);
}
function closePicker() {
picker.style.display = 'none';
applyPick = null;
document.removeEventListener('mousedown', onDocDown);
}
function onDocDown(e) {
if (!picker.contains(e.target)) closePicker();
if (!menu.contains(e.target)) closeMenu();
}
pkGrid.addEventListener('click', e => {
const b = e.target.closest('.pk-e');
if (!b || !applyPick) return;
applyPick(b.textContent);
closePicker();
});
pkRemove.addEventListener('click', () => { if (applyPick) applyPick(''); closePicker(); });
document.getElementById('pk-random').addEventListener('click', () => {
if (applyPick) applyPick(EMOJIS[Math.floor(Math.random() * EMOJIS.length)]);
closePicker();
});
/* ============================= 方案 C 的 kebab 菜单 ============================= */
const menu = document.getElementById('menu');
let menuRow = null;
function openMenu(anchor, se) {
menuRow = se;
menu.style.display = 'block';
const r = anchor.getBoundingClientRect();
const mw = menu.offsetWidth, mh = menu.offsetHeight;
let top = r.bottom + 4;
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - mh - 4);
menu.style.top = top + 'px';
menu.style.left = Math.max(8, r.right - mw) + 'px';
setTimeout(() => document.addEventListener('mousedown', onDocDown), 0);
}
function closeMenu() {
menu.style.display = 'none';
menuRow = null;
}
menu.addEventListener('click', e => {
const mi = e.target.closest('.mi');
if (!mi || !menuRow) return;
const se = menuRow;
closeMenu();
if (mi.dataset.act === 'emoji') {
openPicker(se.querySelector('.t .e') || se.querySelector('.t'), se.dataset.emoji, em => {
se.dataset.emoji = em; renderRow(se);
});
}
/* rename / archive 在原型中不实现 —— 重点是 emoji 入口 */
});
/* ============================= 事件委派 ============================= */
document.addEventListener('click', e => {
const se = e.target.closest('.se');
if (!se) return;
if (e.target.closest('.es, .t .e, .ov')) { // 三个方案的 emoji 触发点
e.stopPropagation();
openPicker(e.target.closest('.es, .t .e, .ov'), se.dataset.emoji, em => {
se.dataset.emoji = em; renderRow(se);
});
return;
}
const kb = e.target.closest('.kb');
if (kb) { e.stopPropagation(); openMenu(kb, se); }
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closePicker(); closeMenu(); }
if (e.key === 'Enter' && e.target.classList && e.target.classList.contains('e')) {
const se = e.target.closest('.se');
openPicker(e.target, se.dataset.emoji, em => { se.dataset.emoji = em; renderRow(se); });
}
});
</script>
</body>
</html>

View file

@ -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"

View file

@ -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);
}

View file

@ -0,0 +1,2 @@
export * from './emojiData';
export * from './sessionEmoji';

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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 });
}
});
});

View file

@ -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 ✓',

View file

@ -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: '已复制 ✓',

View file

@ -4,6 +4,10 @@
<script setup lang="ts">
import { ref } from 'vue';
// `menu` for action lists (the default); `dialog` for popovers whose content
// isn't menuitems (search inputs, grids e.g. SessionEmojiPicker).
withDefaults(defineProps<{ role?: 'menu' | 'dialog' }>(), { role: 'menu' });
// Expose the panel element so call sites can anchor / outside-click against the
// menu surface (positioning is intentionally left to the consumer).
const el = ref<HTMLElement>();
@ -11,7 +15,7 @@ defineExpose({ el });
</script>
<template>
<div ref="el" class="ui-menu" role="menu">
<div ref="el" class="ui-menu" :role="role">
<slot />
</div>
</template>

View file

@ -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] }>();
</script>
@ -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)"
>