From a161badad817024f17f7addb6ea081c120529e9b Mon Sep 17 00:00:00 2001 From: Ark <354959692@qq.com> Date: Wed, 13 May 2026 12:39:44 +0800 Subject: [PATCH] feat(stage-ui): add copy feedback to chat action menu (#1689) --------- Co-authored-by: Neko Co-authored-by-agent: Unknown --- .../chat/components/action-menu/index.test.ts | 38 ++++- .../chat/components/action-menu/index.ts | 6 +- .../chat/components/action-menu/index.vue | 152 +++++++----------- .../chat/components/action-menu/menu-items.ts | 74 +++++++++ 4 files changed, 173 insertions(+), 97 deletions(-) diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.test.ts b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.test.ts index c4f834533..6d070eea3 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.test.ts +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createChatActionMenuItems } from './menu-items' +import { createChatActionMenuItems, createChatActionMenuTriggerState } from './menu-items' /** * @example @@ -44,3 +44,39 @@ describe('createChatActionMenuItems', () => { expect(items.map(item => item.action)).toEqual(['copy', 'delete']) }) }) + +/** + * @example + * describe('createChatActionMenuTriggerState', () => { + * it('uses a success checkmark while copy feedback is active', () => {}) + * }) + */ +describe('createChatActionMenuTriggerState', () => { + /** + * @example + * it('uses a success checkmark while copy feedback is active', () => { + * const state = createChatActionMenuTriggerState({ copyFeedbackActive: true }) + * expect(state.tone).toBe('success') + * }) + */ + it('uses a success checkmark while copy feedback is active', () => { + const state = createChatActionMenuTriggerState({ copyFeedbackActive: true }) + + expect(state.icon).toBe('i-carbon:checkmark') + expect(state.tone).toBe('success') + }) + + /** + * @example + * it('uses the default menu icon without copy feedback', () => { + * const state = createChatActionMenuTriggerState({}) + * expect(state.tone).toBe('default') + * }) + */ + it('uses the default menu icon without copy feedback', () => { + const state = createChatActionMenuTriggerState({}) + + expect(state.icon).toBe('i-solar:menu-dots-bold') + expect(state.tone).toBe('default') + }) +}) diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.ts b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.ts index 56a5cea69..b99c5d743 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.ts +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.ts @@ -1,3 +1,5 @@ export { default as ChatActionMenu } from './index.vue' -export type { ChatActionMenuAction, ChatActionMenuItem } from './menu-items' -export { createChatActionMenuItems } from './menu-items' + +export type { ChatActionMenuAction, ChatActionMenuItem, ChatActionMenuTriggerState } from './menu-items' + +export { createChatActionMenuItems, createChatActionMenuTriggerState } from './menu-items' diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue index 7fd29f263..4225f6ade 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue @@ -4,6 +4,7 @@ import type { ComponentPublicInstance } from 'vue' import type { ChatActionMenuAction } from '.' +import { errorMessageFrom } from '@moeru/std' import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared' import { useElementVisibility, useIntervalFn } from '@vueuse/core' import { createTimeline } from 'animejs' @@ -24,7 +25,7 @@ import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, wat import { useI18n } from 'vue-i18n' import { useWebHaptics } from 'web-haptics/vue' -import { createChatActionMenuItems } from '.' +import { createChatActionMenuItems, createChatActionMenuTriggerState } from '.' import { useBreakpoints } from '../../../../../composables/use-breakpoints' import { useElementScroll } from '../../composables/use-element-scroll' import { chatScrollContainerKey } from '../../constants' @@ -61,6 +62,7 @@ const bottomSentinelRef = useTemplateRef('bottomSentinel') const injectedScrollContainer = inject(chatScrollContainerKey, undefined) const scrollTarget = computed(() => injectedScrollContainer?.value ?? null) const contextMenuOpen = shallowRef(false) +const dropdownMenuOpen = shallowRef(false) const { innerHeight, innerTop, @@ -85,6 +87,7 @@ const { trigger } = useWebHaptics() const { isMobile } = useBreakpoints() const { t } = useI18n() const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value) +const copyFeedbackActive = shallowRef(false) const menuItems = computed(() => createChatActionMenuItems({ canCopy: props.canCopy && props.copyText.trim().length > 0, @@ -92,8 +95,11 @@ const menuItems = computed(() => createChatActionMenuItems({ canDelete: props.canDelete, retryLabel: t('stage.chat.actions.retry'), })) +const triggerState = computed(() => createChatActionMenuTriggerState({ + copyFeedbackActive: copyFeedbackActive.value, +})) const hasMenuItems = computed(() => menuItems.value.length > 0) -const forceVisible = computed(() => contextMenuOpen.value) +const forceVisible = computed(() => contextMenuOpen.value || dropdownMenuOpen.value) const contentClasses = [ 'z-10000 min-w-36 rounded-xl p-1 shadow-md outline-none', @@ -122,46 +128,22 @@ const floatingTop = computed(() => { return clamp(relativeInnerMiddle, 0, Math.max(elementHeight.value - buttonSize, 0)) }) -const showFloatingTrigger = computed(() => { - if (!hasMenuItems.value || !messageIsVisible.value) - return false +const showFloatingTrigger = computed(() => !topIsVisible.value) - return !topIsVisible.value || forceVisible.value -}) - -const floatingTriggerStyle = computed(() => ( +const triggerStyle = computed(() => ( bottomIsVisible.value ? undefined : { top: `${floatingTop.value}px` } )) -const inlineTriggerStyle = computed(() => ( - bottomIsVisible.value - ? undefined - : { top: `${floatingTop.value}px` } -)) - -async function handleAction(action: ChatActionMenuAction) { - if (action === 'copy') { - if (props.copyText.trim()) { - await navigator.clipboard.writeText(props.copyText) - emit('copy') - } - return - } - - if (action === 'retry') { - emit('retry') - return - } - - emit('delete') -} - function handleContextMenuOpenChange(open: boolean) { contextMenuOpen.value = open } +function handleDropdownMenuOpenChange(open: boolean) { + dropdownMenuOpen.value = open +} + function setMeasuredElement(element: Element | ComponentPublicInstance | null) { measuredElementRef.value = element instanceof HTMLElement ? element : null } @@ -265,6 +247,37 @@ function useSetTimeoutFn(fn: () => void, options?: { delay?: number, onClear?: ( const { isTouching } = useTouching(contextMenuContainerElementRef) +const { trigger: triggerCopyFeedbackReset, clear: clearCopyFeedbackReset } = useSetTimeoutFn(() => { + copyFeedbackActive.value = false +}, { delay: 1000 }) + +async function handleAction(action: ChatActionMenuAction) { + if (action === 'copy') { + if (!props.copyText.trim()) + return + + try { + await navigator.clipboard.writeText(props.copyText) + copyFeedbackActive.value = true + clearCopyFeedbackReset() + emit('copy') + triggerCopyFeedbackReset() + } + catch (error) { + console.error('Failed to copy text:', errorMessageFrom(error) ?? String(error)) + } + + return + } + + if (action === 'retry') { + emit('retry') + return + } + + emit('delete') +} + const pressedAnimatable = reactive({ scale: 100 }) const tl = createTimeline({ defaults: { duration: 500, autoplay: false } }) .add(pressedAnimatable, { scale: 90, ease: 'inOut', autoplay: false }) @@ -318,28 +331,35 @@ watch(isTouching, (val) => { class="pointer-events-none absolute inset-x-0 bottom-0 h-px opacity-0" /> - + @@ -369,62 +389,6 @@ watch(isTouching, (val) => { - - -
- - - -
- - - - -
- {{ item.label }} - - - -
diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/menu-items.ts b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/menu-items.ts index b3022ca98..a62109ff4 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/menu-items.ts +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/menu-items.ts @@ -1,12 +1,57 @@ +/** + * Represents supported chat message action identifiers. + */ export type ChatActionMenuAction = 'copy' | 'retry' | 'delete' +/** + * Represents one visible action in a chat message action menu. + */ export interface ChatActionMenuItem { + /** + * Action emitted when the menu item is selected. + */ action: ChatActionMenuAction + /** + * Human-readable menu label. + */ label: string + /** + * UnoCSS Iconify class used for the item icon. + */ icon: string + /** + * Marks destructive actions for danger styling. + */ danger?: boolean } +/** + * Represents the visual state for the compact action menu trigger. + */ +export interface ChatActionMenuTriggerState { + /** + * UnoCSS Iconify class used for the trigger icon. + */ + icon: string + /** + * Visual tone applied to the trigger icon. + */ + tone: 'default' | 'success' +} + +/** + * Creates chat action menu items from action availability flags. + * + * Use when: + * - Rendering dropdown or context menu entries for a chat message + * - Keeping action ordering consistent across menu surfaces + * + * Expects: + * - Boolean flags already reflect message capability and visibility rules + * + * Returns: + * - Menu items ordered as copy, retry, delete + */ export function createChatActionMenuItems(options: { canCopy: boolean canRetry: boolean @@ -38,3 +83,32 @@ export function createChatActionMenuItems(options: { : null, ].filter(Boolean) as ChatActionMenuItem[] } + +/** + * Creates the compact trigger icon state for chat action menus. + * + * Use when: + * - Rendering trigger feedback after a copy action + * - Keeping trigger icon and tone selection outside the Vue template + * + * Expects: + * - `copyFeedbackActive` is true only while copy feedback should be visible + * + * Returns: + * - A default menu icon state or a success checkmark state + */ +export function createChatActionMenuTriggerState(options: { + copyFeedbackActive?: boolean +}): ChatActionMenuTriggerState { + if (options.copyFeedbackActive) { + return { + icon: 'i-carbon:checkmark', + tone: 'success', + } + } + + return { + icon: 'i-solar:menu-dots-bold', + tone: 'default', + } +}