feat: Add FloatingResponse component with bubbleResponse MCP tool

Add a floating response panel that allows the agent to display messages
directly in the UI instead of through the terminal. Includes support for
info, success, warning, and error message types with auto-dismiss.
This commit is contained in:
2026-02-13 19:55:17 -06:00
parent 607527d98d
commit 86b3246fa1
7 changed files with 557 additions and 3 deletions

View File

@@ -0,0 +1,402 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
export interface ResponseMessage {
id: string
message: string
type: 'info' | 'success' | 'warning' | 'error'
timestamp: number
}
const messages = ref<ResponseMessage[]>([])
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const isVisible = computed(() => messages.value.length > 0)
// Default position: bottom-left, above the terminal FAB
function getDefaultPosition() {
return {
x: 20,
y: window.innerHeight - 120
}
}
function addMessage(message: string, type: ResponseMessage['type'] = 'info') {
const id = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// If no custom position, set default position
if (!hasCustomPosition.value) {
position.value = getDefaultPosition()
}
messages.value.push({
id,
message,
type,
timestamp: Date.now()
})
// Auto-dismiss after 10 seconds for non-error messages
if (type !== 'error') {
setTimeout(() => {
removeMessage(id)
}, 10000)
}
return id
}
function removeMessage(id: string) {
const index = messages.value.findIndex(m => m.id === id)
if (index !== -1) {
messages.value.splice(index, 1)
}
}
function clearAll() {
messages.value = []
}
function startDrag(e: MouseEvent) {
if ((e.target as HTMLElement).closest('.close-btn')) return
isDragging.value = true
const container = document.querySelector('.floating-response') as HTMLElement
if (container) {
const rect = container.getBoundingClientRect()
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const newX = e.clientX - dragOffset.value.x
const newY = e.clientY - dragOffset.value.y
// Keep within viewport with some tolerance
const maxX = window.innerWidth - 100
const maxY = window.innerHeight - 50
position.value = {
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
const containerStyle = computed(() => {
if (!hasCustomPosition.value) {
return {
bottom: '120px',
left: '20px'
}
}
return {
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
function getTypeIcon(type: ResponseMessage['type']) {
switch (type) {
case 'success': return '✓'
case 'warning': return '⚠'
case 'error': return '✕'
default: return ''
}
}
function getTypeColor(type: ResponseMessage['type']) {
switch (type) {
case 'success': return '#10b981'
case 'warning': return '#f59e0b'
case 'error': return '#ef4444'
default: return '#6366f1'
}
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
})
// Expose controls for MCP tools
defineExpose({
addMessage,
removeMessage,
clearAll,
getMessages: () => messages.value,
move: (x: number, y: number) => {
position.value = { x, y }
hasCustomPosition.value = true
}
})
</script>
<template>
<Teleport to="body">
<Transition name="bubble-slide">
<div
v-if="isVisible"
class="floating-response"
:class="{ dragging: isDragging }"
:style="containerStyle"
>
<div class="response-glass">
<!-- Header -->
<div class="response-header" @mousedown="startDrag">
<div class="header-left">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Agent Response</span>
</div>
<button class="close-btn" @click="clearAll" title="Clear all">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
<!-- Messages -->
<div class="messages-container">
<TransitionGroup name="message">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:style="{ '--type-color': getTypeColor(msg.type) }"
>
<span class="type-icon" :class="msg.type">{{ getTypeIcon(msg.type) }}</span>
<span class="message-text">{{ msg.message }}</span>
<button class="dismiss-btn" @click="removeMessage(msg.id)">
<svg width="8" height="8" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
</TransitionGroup>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.floating-response {
position: fixed;
min-width: 280px;
max-width: 420px;
z-index: 9997;
}
.response-glass {
background: rgba(200, 215, 235, 0.45);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 0 0 1px rgba(80, 120, 180, 0.25),
0 8px 32px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.response-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
cursor: grab;
user-select: none;
}
.floating-response.dragging .response-header {
cursor: grabbing;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
color: #333;
font: 500 11px/1 system-ui, sans-serif;
}
.header-left svg {
opacity: 0.7;
}
.close-btn {
width: 18px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 3px;
color: #555;
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
background: linear-gradient(180deg, #e66 0%, #c33 100%);
border-color: #a22;
color: #fff;
}
.messages-container {
padding: 8px;
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.message-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.5);
border-radius: 6px;
border-left: 3px solid var(--type-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.type-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 10px;
font-weight: 600;
color: white;
background: var(--type-color);
}
.message-text {
flex: 1;
font-size: 12px;
line-height: 1.4;
color: #333;
word-break: break-word;
}
.dismiss-btn {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 3px;
color: #999;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
}
.message-item:hover .dismiss-btn {
opacity: 1;
}
.dismiss-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #666;
}
/* Scrollbar */
.messages-container::-webkit-scrollbar {
width: 6px;
}
.messages-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
/* Animations */
.bubble-slide-enter-active,
.bubble-slide-leave-active {
transition: all 0.25s ease;
}
.bubble-slide-enter-from,
.bubble-slide-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.message-enter-active {
transition: all 0.2s ease;
}
.message-leave-active {
transition: all 0.15s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.message-leave-to {
opacity: 0;
transform: translateX(10px);
}
@media (max-width: 480px) {
.floating-response {
left: 10px !important;
right: 10px !important;
bottom: 80px !important;
max-width: none;
min-width: auto;
}
}
</style>

View File

@@ -4,6 +4,8 @@ import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { connectWithToken, stopTokenPolling } from '../services/webmcp'
import { useCanvasStore } from '../stores/canvas'
const props = defineProps<{
modelValue: boolean
@@ -18,6 +20,8 @@ const isOpen = computed({
set: (val) => emit('update:modelValue', val)
})
const canvasStore = useCanvasStore()
const terminalContainer = ref<HTMLElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const connected = ref(false)
@@ -38,6 +42,11 @@ let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
// Buffer for detecting WebMCP token
let tokenBuffer = ''
let tokenTimeout: number | null = null
const waitingForToken = ref(false)
const WS_URL = `ws://${window.location.hostname}:4103`
// Mouse position tracking for Ctrl+E
@@ -282,6 +291,38 @@ async function connect() {
} else if (msg.type === 'replay') {
terminal?.write(msg.data)
} else if (msg.type === 'output') {
// Only detect token when waiting for it
if (waitingForToken.value) {
tokenBuffer += msg.data
// Debounce: process buffer after output stops (300ms)
if (tokenTimeout) clearTimeout(tokenTimeout)
tokenTimeout = window.setTimeout(() => {
if (tokenBuffer.includes('Token copiado')) {
// Clean ANSI codes and whitespace
const clean = tokenBuffer.replace(/\x1b\[[0-9;]*m/g, '').replace(/[\r\n\s]/g, '')
const match = clean.match(/eyJ[A-Za-z0-9_\-+/=]+/)
if (match) {
try {
const decoded = atob(match[0])
JSON.parse(decoded)
console.log('[Terminal] WebMCP token detected:', match[0])
waitingForToken.value = false
tokenBuffer = ''
stopTokenPolling()
connectWithToken(match[0]).then(success => {
if (success) {
canvasStore.showNotification('WebMCP connected!', 'success')
}
}).catch(console.error)
} catch {
// Token incomplete, keep waiting
}
}
}
}, 300)
}
terminal?.write(msg.data)
} else if (msg.type === 'exit') {
terminal?.write(msg.data)
@@ -314,6 +355,14 @@ function runClaude() {
}
}
function requestToken() {
if (socket && socket.readyState === WebSocket.OPEN) {
tokenBuffer = ''
waitingForToken.value = true
socket.send(JSON.stringify({ type: 'input', data: 'genera token usando tu mcp\r' }))
}
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
@@ -330,6 +379,9 @@ watch(isOpen, async (open) => {
terminal?.dispose()
terminal = null
fitAddon = null
waitingForToken.value = false
tokenBuffer = ''
if (tokenTimeout) clearTimeout(tokenTimeout)
}
})
@@ -410,6 +462,7 @@ defineExpose({
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
</div>
<div class="window-controls">
<button @click="requestToken" :class="{ waiting: waitingForToken }" title="Connect MCP"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
<button @click="runClaude" title="Claude"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></button>
<button class="x" @click="close" title="Close"><svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
@@ -514,6 +567,11 @@ defineExpose({
border-color: #a22;
color: #fff;
}
.window-controls button.waiting {
background: rgba(16, 185, 129, 0.3);
border-color: #10b981;
animation: pulse 0.8s infinite;
}
.content {
flex: 1;