feat(chat): add right-click context menus on messages (M8.9)

Reusable ContextMenu.vue glass-card component positioned at cursor.
Messages get Copy, Reply, Edit (user), Regenerate (assistant), and
Branch from here options. Closes on Escape or outside click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:15:26 +00:00
co-authored by Claude Opus 4.6
parent 37e8aec920
commit cc5ecf3091
3 changed files with 151 additions and 0 deletions
@@ -3,6 +3,7 @@
class="group/msg flex animate-fade-up-fast"
:class="isUser ? 'justify-end' : 'justify-start'"
:style="{ animationDelay: `${index * 30}ms` }"
@contextmenu.prevent="openContextMenu"
>
<div class="relative max-w-[85%] md:max-w-[75%]">
<!-- Action buttons (hover on desktop, always visible on touch) -->
@@ -248,12 +249,23 @@
</div>
</div>
</div>
<!-- Context menu -->
<ContextMenu ref="contextMenuRef">
<ContextMenuItem v-if="isUser" @click="startEditingFromMenu">Edit</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitRegenerate">Regenerate</ContextMenuItem>
<ContextMenuItem @click="emitReply">Reply</ContextMenuItem>
<ContextMenuItem v-if="!isUser" @click="emitBranch">Branch from here</ContextMenuItem>
<ContextMenuItem @click="copyContent">Copy</ContextMenuItem>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
import type { Message, WebSearchResult } from '@aiui/core/types/message'
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
@@ -497,4 +509,36 @@ function handleBubbleClick() {
}
if (hasContext.value) openPanel()
}
// Context menu
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
function openContextMenu(e: MouseEvent) {
contextMenuRef.value?.open(e.clientX, e.clientY)
}
function startEditingFromMenu() {
contextMenuRef.value?.close()
startEditing()
}
function emitRegenerate() {
contextMenuRef.value?.close()
emit('regenerate')
}
function emitReply() {
contextMenuRef.value?.close()
emit('reply', props.message.id, props.message.content)
}
function emitBranch() {
contextMenuRef.value?.close()
emit('branch', props.message.id)
}
function copyContent() {
contextMenuRef.value?.close()
navigator.clipboard.writeText(props.message.content).catch(() => {})
}
</script>
@@ -0,0 +1,82 @@
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9998]"
aria-hidden="true"
@click="close"
@contextmenu.prevent="close"
/>
<Transition name="context-menu">
<div
v-if="isOpen"
class="fixed z-[9999] glass-card p-1.5 rounded-xl shadow-2xl animate-scale-in min-w-[140px]"
:style="positionStyle"
role="menu"
@click.stop
>
<slot />
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
const isOpen = ref(false)
const x = ref(0)
const y = ref(0)
const positionStyle = computed(() => {
// Keep menu within viewport
const menuWidth = 160
const menuHeight = 200
const adjustedX = Math.min(x.value, window.innerWidth - menuWidth - 8)
const adjustedY = Math.min(y.value, window.innerHeight - menuHeight - 8)
return {
left: `${Math.max(8, adjustedX)}px`,
top: `${Math.max(8, adjustedY)}px`,
}
})
function open(clientX: number, clientY: number) {
x.value = clientX
y.value = clientY
isOpen.value = true
}
function close() {
isOpen.value = false
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) {
close()
}
}
onMounted(() => {
document.addEventListener('keydown', handleEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleEscape)
})
defineExpose({ open, close, isOpen })
</script>
<style scoped>
.context-menu-enter-active {
transition: all 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
.context-menu-leave-active {
transition: all 0.1s ease-in;
}
.context-menu-enter-from,
.context-menu-leave-to {
opacity: 0;
transform: scale(0.95);
}
</style>
@@ -0,0 +1,25 @@
<template>
<button
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all flex items-center gap-2"
:class="destructive
? 'text-red-400/80 hover:text-red-400 hover:bg-white/10'
: 'text-white/60 hover:text-white hover:bg-white/10'"
role="menuitem"
@click="$emit('click')"
>
<slot />
</button>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
destructive?: boolean
}>(),
{ destructive: false }
)
defineEmits<{
click: []
}>()
</script>