feat(stage-ui): add copy feedback to chat action menu (#1689)

---------

Co-authored-by: Neko <neko@ayaka.moe>
Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
Ark
2026-05-13 12:39:44 +08:00
committed by GitHub
co-authored by Neko
parent 746e486628
commit a161badad8
4 changed files with 173 additions and 97 deletions
@@ -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')
})
})
@@ -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'
@@ -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<HTMLDivElement>('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"
/>
<DropdownMenuRoot>
<DropdownMenuRoot @update:open="handleDropdownMenuOpenChange">
<DropdownMenuTrigger
v-if="!shouldDisableDropdownMenu"
v-if="hasMenuItems && !shouldDisableDropdownMenu"
as-child
:class="[
'absolute z-10 opacity-0 transition-opacity duration-200',
'group-hover/chat-action:opacity-100 group-focus-within/chat-action:opacity-100',
forceVisible ? 'opacity-100' : '',
props.placement === 'left' ? 'left-0 top-0 translate-x-[calc(-100%-8px)]' : 'right-0 top-0 translate-x-[calc(100%+8px)]',
props.placement === 'left' ? 'left-0 translate-x-[calc(-100%-8px)]' : 'right-0 translate-x-[calc(100%+8px)]',
showFloatingTrigger && bottomIsVisible ? 'bottom-0' : 'top-0',
]"
:style="inlineTriggerStyle"
:style="triggerStyle"
>
<button
:class="[
'pointer-events-auto h-8 w-8 flex items-center justify-center rounded-lg',
'h-8 w-8 flex items-center justify-center rounded-lg',
'bg-white/85 text-neutral-500 backdrop-blur-sm',
'dark:bg-neutral-900/85 dark:text-neutral-300',
'transition-colors hover:text-primary-500 dark:hover:text-primary-300',
]"
:aria-label="menuLabel"
>
<div class="i-solar:menu-dots-bold text-base" />
<div
:class="[
triggerState.icon,
'text-base',
triggerState.tone === 'success' ? 'text-emerald-600 dark:text-emerald-300' : '',
]"
/>
</button>
</DropdownMenuTrigger>
@@ -369,62 +389,6 @@ watch(isTouching, (val) => {
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="showFloatingTrigger">
<div
:class="[
'pointer-events-none flex absolute',
'group-hover/chat-action:opacity-100 group-focus-within/chat-action:opacity-100',
'transition-opacity duration-200',
forceVisible ? 'opacity-100' : '',
props.placement === 'left' ? 'left-0' : 'right-0',
props.placement === 'left' ? 'translate-x-[calc(-100%-8px)]' : 'translate-x-[calc(100%+8px)]',
bottomIsVisible ? 'bottom-0' : 'top-0',
]"
:style="floatingTriggerStyle"
>
<DropdownMenuTrigger
v-if="!shouldDisableDropdownMenu"
as-child
>
<button
:class="[
'pointer-events-auto h-8 w-8 flex items-center justify-center rounded-lg',
'bg-white/85 text-neutral-500 backdrop-blur-sm',
'dark:bg-neutral-900/85 dark:text-neutral-300',
'transition-colors hover:text-primary-500 dark:hover:text-primary-300',
]"
:aria-label="menuLabel"
>
<div class="i-solar:menu-dots-bold text-base" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuPortal>
<DropdownMenuContent
align="end"
side="bottom"
:side-offset="6"
:class="contentClasses"
>
<DropdownMenuItem
v-for="item in menuItems"
:key="`${item.action}-floating`"
:class="[
...itemClasses,
item.danger
? 'text-red-500 data-[highlighted]:bg-red-50/80 dark:data-[highlighted]:bg-red-950/40'
: '',
]"
@select="() => void handleAction(item.action)"
>
<div :class="[item.icon, 'text-xs']" />
<span>{{ item.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</ContextMenuTrigger>
@@ -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',
}
}