refactor: Split AgentBar into modular components with PromptBar chat flow

Extract 1226-line monolithic AgentBar.vue into focused components:
- types/agent.ts: shared types (Agent, AgentStatusState, ClaudeStatus, ConversationEntry)
- agent/FloatBubble.vue: bubble with all status/ejecutor animations, hold detection, recording audio bars
- agent/PromptBar.vue: floating panel with chat conversation, transcript, history
- agent/ChatInput.vue: reusable input row (text, mic, send, history buttons)
- agent/TranscriptCard.vue: typewriter transcription simulation
- agent/ResponseCard.vue: thinking dots + mock response
- agent/ConversationHistory.vue: scrollable mock history entries
- AgentBar.vue: thin orchestrator (~290 lines) keeping WebSocket + status logic

New interaction: click bubble opens PromptBar in text mode, hold opens in
recording mode with audio bar animation on the bubble. Spring enter/blur
exit animations on PromptBar. Text submit shows chat bubbles with mock
agent responses.
This commit is contained in:
2026-02-15 19:33:29 -06:00
parent ffceb2efc2
commit 68edc01d44
8 changed files with 1741 additions and 1013 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{
placeholder?: string
recording?: boolean
historyActive?: boolean
autofocus?: boolean
}>()
const emit = defineEmits<{
submit: [text: string]
mic: []
'toggle-history': []
}>()
const inputText = ref('')
const inputEl = ref<HTMLInputElement | null>(null)
function handleSubmit() {
const text = inputText.value.trim()
if (!text) return
emit('submit', text)
inputText.value = ''
}
function focus() {
inputEl.value?.focus()
}
watch(() => props.autofocus, async (v) => {
if (v) {
await nextTick()
focus()
}
}, { immediate: true })
defineExpose({ focus })
</script>
<template>
<div class="chat-input-row">
<input
ref="inputEl"
v-model="inputText"
class="chat-input"
type="text"
:placeholder="placeholder || 'Escribe un mensaje...'"
@keydown.enter="handleSubmit"
/>
<button
class="ci-btn ci-mic"
:class="{ recording }"
:title="recording ? 'Grabando...' : 'Grabar voz'"
@click="emit('mic')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
<button
class="ci-btn ci-send"
title="Enviar"
@click="handleSubmit"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
<button
class="ci-btn ci-history"
:class="{ active: historyActive }"
title="Historial"
@click="emit('toggle-history')"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</button>
</div>
</template>
<style scoped>
.chat-input-row {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-input {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
outline: none;
transition: border-color 0.15s;
font-family: system-ui, sans-serif;
}
.chat-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.chat-input:focus {
border-color: rgba(255, 255, 255, 0.2);
}
.ci-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.ci-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.ci-mic.recording {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
animation: mic-pulse 1s ease-in-out infinite;
}
.ci-send:hover {
background: rgba(139, 92, 246, 0.2);
border-color: rgba(139, 92, 246, 0.3);
color: rgba(139, 92, 246, 0.9);
}
.ci-history.active {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
}
@keyframes mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.3); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { Agent, ConversationEntry } from '../../types/agent'
defineProps<{
agent: Agent
}>()
const mockEntries: ConversationEntry[] = [
{
id: '1',
role: 'user',
content: 'Revisa el módulo de autenticación y encuentra los errores de validación de tokens.',
timestamp: '14:32',
method: 'voice'
},
{
id: '2',
role: 'agent',
content: 'Encontré 3 issues en el validador de tokens JWT: expiración no verificada en refresh, falta sanitización del header Authorization, y el middleware no maneja tokens revocados.',
timestamp: '14:33',
method: 'text'
},
{
id: '3',
role: 'user',
content: 'Corrige los tres problemas y agrega tests unitarios para cada caso.',
timestamp: '14:35',
method: 'text'
},
{
id: '4',
role: 'agent',
content: 'Corregidos los 3 issues. Se agregaron 8 tests unitarios cubriendo cada caso: token expirado, header malformado, token revocado, y sus variantes edge-case. Todos los tests pasan.',
timestamp: '14:38',
method: 'text'
},
{
id: '5',
role: 'user',
content: 'Ahora implementa un sistema de rate limiting para la API de login con un máximo de 5 intentos por minuto.',
timestamp: '15:01',
method: 'voice'
},
{
id: '6',
role: 'agent',
content: 'Rate limiter implementado usando sliding window en Redis. Configurado a 5 intentos/minuto por IP. Retorna HTTP 429 con header Retry-After cuando se excede el límite.',
timestamp: '15:04',
method: 'text'
}
]
</script>
<template>
<div class="conversation-history">
<div class="history-header">
<span class="history-title">Historial</span>
<span class="history-count">{{ mockEntries.length }}</span>
</div>
<div class="history-list">
<div
v-for="entry in mockEntries"
:key="entry.id"
class="history-entry"
:class="entry.role"
>
<div class="entry-meta">
<span class="role-badge" :class="entry.role">
{{ entry.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
</span>
<span class="entry-time">{{ entry.timestamp }}</span>
<!-- Mic icon for voice entries -->
<svg v-if="entry.method === 'voice'" class="method-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</div>
<div class="entry-content">{{ entry.content }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.conversation-history {
margin-top: 8px;
animation: slide-in 0.2s ease-out;
}
.history-header {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
}
.history-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.4);
}
.history-count {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 8px;
}
.history-list {
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
.history-entry {
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.history-entry.agent {
background: rgba(139, 92, 246, 0.06);
border-color: rgba(139, 92, 246, 0.1);
}
.entry-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.role-badge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 1px 6px;
border-radius: 4px;
}
.role-badge.user {
color: rgba(59, 130, 246, 0.9);
background: rgba(59, 130, 246, 0.12);
}
.role-badge.agent {
color: rgba(139, 92, 246, 0.9);
background: rgba(139, 92, 246, 0.12);
}
.entry-time {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
}
.method-icon {
color: rgba(255, 255, 255, 0.25);
}
.entry-content {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,572 @@
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import type { Agent, AgentStatusState } from '../../types/agent'
const props = defineProps<{
agent: Agent
status: AgentStatusState | undefined
recording?: boolean
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
hold: [el: HTMLElement]
}>()
const HOLD_MS = 400
let holdTimer: number | null = null
let didHold = false
let holdTarget: HTMLElement | null = null
function onPointerDown(e: PointerEvent) {
didHold = false
holdTarget = e.currentTarget as HTMLElement
holdTarget.setPointerCapture(e.pointerId)
holdTimer = window.setTimeout(() => {
didHold = true
emit('hold', holdTarget!)
}, HOLD_MS)
}
function onPointerUp(e: PointerEvent) {
clearHold()
if (!didHold) {
emit('click', e as unknown as MouseEvent)
}
}
function onPointerCancel() {
clearHold()
}
function clearHold() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
}
onBeforeUnmount(clearHold)
function bubbleClasses() {
const s = props.status
const base: Record<string, boolean> = { recording: !!props.recording }
if (!s) return base
return {
...base,
processing: s.isProcessing && !s.isReading && !s.isWriting && !s.awaitingPermission,
reading: s.isReading,
writing: s.isWriting,
permission: s.awaitingPermission,
notification: s.showNotification,
'tool-flash': s.showToolFlash
}
}
function bubbleStyle() {
const c = props.agent.uiConfig?.color || '#6366f1'
const s = props.status
if (props.recording) {
return {
background: props.agent.uiConfig?.gradient || c,
borderColor: 'rgba(239, 68, 68, 0.7)'
}
}
if (s?.awaitingPermission) {
return { background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }
}
if (s?.isWriting) {
return { background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)' }
}
if (s?.isReading) {
return { background: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)' }
}
if (s?.isProcessing) {
return { background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)' }
}
return {
background: props.agent.uiConfig?.gradient || c,
boxShadow: `0 4px 15px ${c}66, 0 8px 30px ${c}4D, inset 0 1px 0 rgba(255,255,255,0.2)`
}
}
function bubbleHoverShadow() {
const c = props.agent.uiConfig?.color || '#6366f1'
return `0 8px 25px ${c}80, 0 15px 40px ${c}59, inset 0 1px 0 rgba(255,255,255,0.25)`
}
function isAnimating(): boolean {
const s = props.status
if (!s) return false
return s.isProcessing || s.isReading || s.isWriting || s.awaitingPermission || s.showNotification || s.showToolFlash
}
function bubbleTitle() {
const s = props.status
const a = props.agent
if (s?.awaitingPermission) {
return `Permiso requerido: ${s.currentTool || 'herramienta'}`
}
if (s?.isProcessing) {
return `${a.uiConfig?.label}: ${s.currentTool || 'processing'}`
}
return a.uiConfig?.label || a.name
}
</script>
<template>
<button
class="agent-bubble"
:class="bubbleClasses()"
:data-agent="agent.id"
:style="bubbleStyle()"
:title="bubbleTitle()"
@pointerdown.prevent="onPointerDown"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
@contextmenu.prevent
@mouseenter="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleHoverShadow())"
@mouseleave="!isAnimating() && (($event.currentTarget as HTMLElement).style.boxShadow = bubbleStyle().boxShadow || '')"
>
<!-- Recording: audio bars -->
<div v-if="recording" class="audio-bars">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<!-- Permission alert icon -->
<svg v-else-if="status?.awaitingPermission" class="bubble-status-icon permission-icon" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<!-- Ejecutor processing: ember ring -->
<div v-else-if="agent.id === 'ejecutor' && status?.isProcessing && !status?.isReading && !status?.isWriting" class="ember-ring">
<span></span>
<span></span>
</div>
<!-- Default processing: thinking dots -->
<div v-else-if="status?.isProcessing && !status?.isReading && !status?.isWriting" class="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
<!-- Reading icon (eye) -->
<svg v-else-if="status?.isReading" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<!-- Writing icon (pencil) -->
<svg v-else-if="status?.isWriting" class="bubble-status-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19l7-7 3 3-7 7-3-3z"/>
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
<path d="M2 2l7.586 7.586"/>
<circle cx="11" cy="11" r="2"/>
</svg>
<!-- Default: agent label -->
<span v-else class="bubble-label">{{ agent.uiConfig?.shortLabel }}</span>
</button>
</template>
<style scoped>
.agent-bubble {
pointer-events: auto;
width: 58px;
height: 58px;
border-radius: 18px;
color: white;
border: 2px solid rgba(255, 255, 255, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: visible;
backdrop-filter: blur(10px);
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
position: relative;
}
/* Hover glow ring */
.agent-bubble::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 21px;
background: inherit;
filter: blur(8px);
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.agent-bubble:hover {
transform: translateY(-3px) scale(1.05);
}
.agent-bubble:hover::before {
opacity: 0.4;
}
.agent-bubble:active {
transform: scale(0.95);
transition-duration: 0.1s;
}
/* ====================== RECORDING STATE ====================== */
.agent-bubble.recording {
animation: rec-glow 1.5s ease-in-out infinite !important;
border-color: rgba(239, 68, 68, 0.7) !important;
}
.audio-bars {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 22px;
}
.audio-bars span {
width: 3px;
border-radius: 2px;
background: #fff;
animation: audio-bar 1.2s ease-in-out infinite;
}
.audio-bars span:nth-child(1) { height: 8px; animation-delay: 0s; }
.audio-bars span:nth-child(2) { height: 16px; animation-delay: 0.15s; }
.audio-bars span:nth-child(3) { height: 22px; animation-delay: 0.3s; }
.audio-bars span:nth-child(4) { height: 14px; animation-delay: 0.45s; }
.audio-bars span:nth-child(5) { height: 10px; animation-delay: 0.6s; }
@keyframes audio-bar {
0%, 100% { transform: scaleY(0.4); opacity: 0.5; }
50% { transform: scaleY(1); opacity: 1; }
}
@keyframes rec-glow {
0%, 100% {
box-shadow:
0 0 0 0 rgba(239, 68, 68, 0.5),
0 4px 15px rgba(239, 68, 68, 0.3) !important;
}
50% {
box-shadow:
0 0 0 8px rgba(239, 68, 68, 0),
0 4px 25px rgba(239, 68, 68, 0.6) !important;
}
}
/* ====================== STATUS ANIMATIONS (DEFAULT) ====================== */
.agent-bubble.processing,
.agent-bubble.reading,
.agent-bubble.writing,
.agent-bubble.permission,
.agent-bubble.notification {
transition: background 0.3s ease !important;
}
.agent-bubble.processing {
animation: ab-processing-pulse 2s ease-in-out infinite !important;
}
.agent-bubble.reading {
animation: ab-reading-scan 1.5s ease-in-out infinite !important;
}
.agent-bubble.writing {
animation: ab-writing-pulse 0.8s ease-in-out infinite !important;
}
.agent-bubble.permission {
animation: ab-permission-pulse 1s ease-in-out infinite !important;
transform: scale(1.1) !important;
z-index: 10000;
}
.agent-bubble.notification {
animation: ab-notification-bounce 0.5s ease-in-out 4 !important;
}
.agent-bubble.tool-flash::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.4);
animation: ab-tool-flash 0.5s ease-out forwards !important;
pointer-events: none;
}
/* Thinking dots */
.thinking-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.thinking-dots span {
width: 5px;
height: 5px;
background: white;
border-radius: 50%;
animation: ab-thinking-dot 1.4s ease-in-out infinite;
}
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
/* Status icons */
.bubble-status-icon {
animation: ab-icon-breathe 1s ease-in-out infinite;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
}
.permission-icon {
animation: ab-permission-shake 0.5s ease-in-out infinite;
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8));
}
/* ====================== EJECUTOR UNIQUE ANIMATIONS ====================== */
.agent-bubble[data-agent="ejecutor"].processing {
animation: ej-heartbeat 1.2s ease-in-out infinite !important;
border-color: rgba(255, 100, 50, 0.5) !important;
}
.agent-bubble[data-agent="ejecutor"].reading {
animation: ej-infrared 1s linear infinite !important;
}
.agent-bubble[data-agent="ejecutor"].writing {
animation: ej-forge 0.6s ease-in-out infinite !important;
border-color: rgba(255, 200, 50, 0.6) !important;
}
.agent-bubble[data-agent="ejecutor"].notification {
animation: ej-glitch 0.15s steps(2) 8 !important;
}
.agent-bubble[data-agent="ejecutor"].tool-flash::after {
background: rgba(239, 68, 68, 0.5) !important;
animation: ej-shockwave 0.6s ease-out forwards !important;
border: 1px solid rgba(255, 100, 50, 0.8);
}
.agent-bubble[data-agent="ejecutor"].tool-flash::before {
content: '' !important;
position: absolute !important;
inset: -2px !important;
border-radius: 20px !important;
background: rgba(255, 150, 50, 0.3) !important;
filter: none !important;
opacity: 1 !important;
animation: ej-shockwave-inner 0.4s ease-out forwards !important;
pointer-events: none !important;
z-index: 1 !important;
}
/* Ember ring */
.ember-ring {
position: relative;
width: 24px;
height: 24px;
}
.ember-ring span {
position: absolute;
inset: 0;
border: 2px solid transparent;
border-radius: 50%;
}
.ember-ring span:nth-child(1) {
border-top-color: #fff;
border-right-color: rgba(255, 200, 50, 0.8);
animation: ej-ember-spin 0.8s linear infinite;
}
.ember-ring span:nth-child(2) {
border-bottom-color: rgba(255, 100, 50, 0.9);
border-left-color: rgba(255, 50, 50, 0.6);
animation: ej-ember-spin 1.2s linear infinite reverse;
}
/* ====================== EJECUTOR KEYFRAMES ====================== */
@keyframes ej-heartbeat {
0% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
14% { transform: scale(1.15) !important; box-shadow: 0 4px 35px rgba(239, 68, 68, 0.7) !important; }
28% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
42% { transform: scale(1.22) !important; box-shadow: 0 4px 45px rgba(255, 100, 50, 0.8) !important; }
70% { transform: scale(1) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.2) !important; }
100% { transform: scale(1) !important; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3) !important; }
}
@keyframes ej-infrared {
0% {
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
transform: rotate(0deg) !important;
}
25% {
box-shadow: 8px 0 20px rgba(239, 68, 68, 0.6), -8px 0 20px rgba(239, 68, 68, 0.1) !important;
}
50% {
box-shadow: 0 8px 20px rgba(239, 68, 68, 0.6), 0 -8px 20px rgba(239, 68, 68, 0.1) !important;
}
75% {
box-shadow: -8px 0 20px rgba(239, 68, 68, 0.6), 8px 0 20px rgba(239, 68, 68, 0.1) !important;
}
100% {
box-shadow: 0 -8px 20px rgba(239, 68, 68, 0.6), 0 8px 20px rgba(239, 68, 68, 0.1) !important;
}
}
@keyframes ej-forge {
0%, 100% {
transform: scale(1) !important;
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4) !important;
filter: brightness(1);
}
50% {
transform: scale(1.06) !important;
box-shadow: 0 4px 30px rgba(255, 200, 50, 0.8), 0 0 15px rgba(255, 255, 255, 0.3) !important;
filter: brightness(1.3);
}
}
@keyframes ej-glitch {
0% { transform: translate(0, 0) skewX(0deg) !important; }
25% { transform: translate(-3px, 1px) skewX(-4deg) !important; box-shadow: -3px 0 8px rgba(0, 255, 255, 0.4), 3px 0 8px rgba(255, 0, 50, 0.4) !important; }
50% { transform: translate(2px, -2px) skewX(3deg) !important; box-shadow: 3px 0 8px rgba(0, 255, 255, 0.4), -3px 0 8px rgba(255, 0, 50, 0.4) !important; }
75% { transform: translate(-1px, 2px) skewX(-2deg) !important; }
100%{ transform: translate(0, 0) skewX(0deg) !important; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4) !important; }
}
@keyframes ej-shockwave {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.6); }
}
@keyframes ej-shockwave-inner {
0% { opacity: 0.8; transform: scale(1); }
100% { opacity: 0; transform: scale(1.3); }
}
@keyframes ej-ember-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ====================== DEFAULT KEYFRAMES ====================== */
@keyframes ab-processing-pulse {
0%, 100% { box-shadow: 0 8px 24px rgba(245, 158, 11, 0.4) !important; }
50% { box-shadow: 0 8px 40px rgba(245, 158, 11, 0.8) !important; }
}
@keyframes ab-reading-scan {
0%, 100% {
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4) !important;
transform: rotate(0deg) !important;
}
25% { transform: rotate(-3deg) !important; }
75% { transform: rotate(3deg) !important; }
50% { box-shadow: 0 8px 40px rgba(6, 182, 212, 0.8) !important; }
}
@keyframes ab-writing-pulse {
0%, 100% {
transform: scale(1) !important;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
}
50% {
transform: scale(1.08) !important;
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.7) !important;
}
}
@keyframes ab-permission-pulse {
0% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7) !important;
transform: scale(1.1) !important;
}
70% {
box-shadow: 0 0 0 15px rgba(239, 68, 68, 0) !important;
transform: scale(1.05) !important;
}
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0) !important;
transform: scale(1.1) !important;
}
}
@keyframes ab-permission-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-2px); }
75% { transform: translateX(2px); }
}
@keyframes ab-notification-bounce {
0%, 100% { transform: translateY(0) !important; }
50% { transform: translateY(-10px) !important; }
}
@keyframes ab-tool-flash {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.4); }
}
@keyframes ab-thinking-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes ab-icon-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ====================== LABELS ====================== */
.bubble-label {
font-weight: 800;
font-size: 15px;
letter-spacing: 0.5px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
line-height: 1;
}
/* Mobile */
@media (max-width: 768px) {
.agent-bubble {
width: 52px;
height: 52px;
border-radius: 16px;
}
.agent-bubble::before {
border-radius: 19px;
}
.bubble-label {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,443 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import type { Agent } from '../../types/agent'
import ChatInput from './ChatInput.vue'
import TranscriptCard from './TranscriptCard.vue'
import ConversationHistory from './ConversationHistory.vue'
interface ChatMessage {
id: number
role: 'user' | 'agent'
content: string
status: 'sent' | 'thinking' | 'done'
}
const MOCK_RESPONSES = [
'Entendido. Revisé el código y encontré el problema — la validación se saltaba cuando el token venía en formato Bearer sin espacio. Ya está corregido.',
'Listo. Implementé los cambios en el componente. Los tests existentes siguen pasando y agregué dos nuevos para cubrir el edge case que mencionas.',
'Analicé la estructura del módulo. Hay 3 archivos que necesitan cambios: el store, el middleware de auth y el composable de sesión. ¿Procedo con los tres?',
'Hecho. El refactor reduce la complejidad ciclomática de 12 a 4. Eliminé las condiciones redundantes y extraje la lógica de retry a un helper separado.',
'Encontré el bug. El problema era una race condition en el useEffect — se desmontaba el componente antes de que la promise resolviera. Agregué un abort controller.',
]
let idCounter = 0
let thinkTimer: number | null = null
const props = defineProps<{
agent: Agent
anchorRect: DOMRect | null
visible: boolean
startRecording?: boolean
}>()
const emit = defineEmits<{
close: []
submit: [text: string]
}>()
const contentEl = ref<HTMLDivElement | null>(null)
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
const isRecording = ref(false)
const showTranscript = ref(false)
const showHistory = ref(false)
const messages = reactive<ChatMessage[]>([])
const panelStyle = computed(() => {
if (!props.anchorRect) return {}
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
const bottomOffset = window.innerHeight - props.anchorRect.top + 12
const panelWidth = 360
let left = bubbleCenterX - panelWidth / 2
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
return {
position: 'fixed' as const,
bottom: `${bottomOffset}px`,
left: `${left}px`,
width: `${panelWidth}px`
}
})
const hasContent = computed(() =>
messages.length > 0 || showTranscript.value || showHistory.value
)
async function scrollToBottom() {
await nextTick()
if (contentEl.value) {
contentEl.value.scrollTop = contentEl.value.scrollHeight
}
}
function pushAgentResponse(agentMsg: ChatMessage) {
const delay = 1200 + Math.random() * 800
thinkTimer = window.setTimeout(() => {
agentMsg.content = MOCK_RESPONSES[Math.floor(Math.random() * MOCK_RESPONSES.length)]
agentMsg.status = 'done'
scrollToBottom()
}, delay)
}
function handleSubmit(text: string) {
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
emit('submit', text)
const agentMsg: ChatMessage = { id: ++idCounter, role: 'agent', content: '', status: 'thinking' }
messages.push(agentMsg)
scrollToBottom()
pushAgentResponse(agentMsg)
}
function handleMic() {
if (showTranscript.value) return
isRecording.value = true
showTranscript.value = true
scrollToBottom()
}
function handleTranscriptDone(text: string) {
isRecording.value = false
showTranscript.value = false
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
const agentMsg: ChatMessage = { id: ++idCounter, role: 'agent', content: '', status: 'thinking' }
messages.push(agentMsg)
scrollToBottom()
pushAgentResponse(agentMsg)
}
function toggleHistory() {
showHistory.value = !showHistory.value
if (showHistory.value) scrollToBottom()
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
watch(() => props.visible, async (v) => {
if (v) {
isRecording.value = false
showTranscript.value = false
showHistory.value = false
messages.length = 0
idCounter = 0
await nextTick()
if (props.startRecording) {
handleMic()
} else {
chatInputEl.value?.focus()
}
}
})
defineExpose({ isRecording })
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearTimeout(thinkTimer)
})
</script>
<template>
<Teleport to="body">
<Transition name="prompt-bar">
<div v-if="visible && anchorRect" class="prompt-bar-backdrop" @click.self="emit('close')">
<div class="prompt-bar-panel" :style="panelStyle">
<!-- Header -->
<div class="pb-header">
<div class="pb-agent-badge" :style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }">
{{ agent.uiConfig?.shortLabel }}
</div>
<span class="pb-agent-label">{{ agent.uiConfig?.label || agent.name }}</span>
<button class="pb-close" @click="emit('close')">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- Conversation content area -->
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
<div
v-for="msg in messages"
:key="msg.id"
class="chat-msg"
:class="msg.role"
>
<template v-if="msg.role === 'user'">
<div class="msg-bubble user-bubble">{{ msg.content }}</div>
</template>
<template v-else>
<div class="msg-bubble agent-bubble">
<div v-if="msg.status === 'thinking'" class="thinking-inline">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div v-else class="agent-text fade-in">{{ msg.content }}</div>
</div>
</template>
</div>
<TranscriptCard v-if="showTranscript" @done="handleTranscriptDone" />
<ConversationHistory v-if="showHistory" :agent="agent" />
</div>
<!-- Input -->
<ChatInput
ref="chatInputEl"
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
:recording="isRecording"
:history-active="showHistory"
:autofocus="visible"
@submit="handleSubmit"
@mic="handleMic"
@toggle-history="toggleHistory"
/>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.prompt-bar-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
}
.prompt-bar-panel {
background: rgba(15, 10, 26, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
overflow: hidden;
transform-origin: bottom center;
}
/* Header */
.pb-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.pb-agent-badge {
width: 26px;
height: 26px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.pb-agent-label {
flex: 1;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
font-family: system-ui, sans-serif;
}
.pb-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.pb-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
/* Content area */
.pb-content {
max-height: 350px;
overflow-y: auto;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
scroll-behavior: smooth;
}
.pb-content.empty {
padding: 0;
max-height: 0;
}
/* Chat messages */
.chat-msg {
display: flex;
animation: msg-in 0.2s ease-out;
}
.chat-msg.user {
justify-content: flex-end;
}
.chat-msg.agent {
justify-content: flex-start;
}
.msg-bubble {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
font-family: system-ui, sans-serif;
word-break: break-word;
}
.user-bubble {
background: rgba(99, 102, 241, 0.25);
border: 1px solid rgba(99, 102, 241, 0.3);
color: rgba(255, 255, 255, 0.92);
border-bottom-right-radius: 4px;
}
.agent-bubble {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
border-bottom-left-radius: 4px;
}
/* Thinking dots inline */
.thinking-inline {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
}
.thinking-inline .dot {
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
animation: dot-bounce 1.4s ease-in-out infinite;
}
.thinking-inline .dot:nth-child(1) { animation-delay: 0s; }
.thinking-inline .dot:nth-child(2) { animation-delay: 0.2s; }
.thinking-inline .dot:nth-child(3) { animation-delay: 0.4s; }
.agent-text {
white-space: pre-wrap;
}
/* Animations */
.fade-in {
animation: fade-in 0.25s ease-out;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* Transition — enter */
.prompt-bar-enter-active {
transition: opacity 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.prompt-bar-enter-active .prompt-bar-panel {
animation: pb-enter 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.prompt-bar-enter-from {
opacity: 0;
}
/* Transition — leave */
.prompt-bar-leave-active {
transition: opacity 0.2s ease;
}
.prompt-bar-leave-active .prompt-bar-panel {
animation: pb-leave 0.2s ease both;
}
.prompt-bar-leave-to {
opacity: 0;
}
@keyframes pb-enter {
0% {
opacity: 0;
transform: translateY(16px) scale(0.85);
filter: blur(4px);
}
60% {
opacity: 1;
filter: blur(0);
}
100% {
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes pb-leave {
0% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
100% {
opacity: 0;
transform: translateY(12px) scale(0.92);
filter: blur(3px);
}
}
/* Mobile */
@media (max-width: 768px) {
.prompt-bar-panel {
left: 8px !important;
right: 8px;
width: auto !important;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = withDefaults(defineProps<{
thinkDelay?: number
}>(), {
thinkDelay: 1500
})
const emit = defineEmits<{
done: []
}>()
const thinking = ref(true)
let timerId: number | null = null
const MOCK_RESPONSE = `He revisado el componente de autenticación y encontré el problema. El flujo actual de invalidación de tokens funciona así:
1. **Servidor** invalida el token en la base de datos al cerrar sesión
2. **Cliente** sigue usando el JWT almacenado en localStorage hasta que expira
La solución que implementé usa un canal WebSocket dedicado para notificaciones de sesión. Cuando un token se revoca, el servidor envía un evento \`session:revoked\` al cliente correspondiente, que automáticamente limpia el estado y redirige al login.
Los archivos modificados fueron:
- \`src/auth/tokenValidator.ts\` — verificación en tiempo real
- \`src/ws/sessionChannel.ts\` — canal de notificaciones
- \`src/stores/auth.ts\` — listener de revocación`
onMounted(() => {
timerId = window.setTimeout(() => {
thinking.value = false
emit('done')
}, props.thinkDelay)
})
onBeforeUnmount(() => {
if (timerId) clearTimeout(timerId)
})
</script>
<template>
<div class="response-card">
<!-- Thinking state -->
<div v-if="thinking" class="thinking-state">
<div class="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="thinking-label">Pensando...</span>
</div>
<!-- Response -->
<div v-else class="response-body fade-in">
<div class="response-badge">Agente</div>
<div class="response-text" v-html="MOCK_RESPONSE.replace(/\n/g, '<br>')"></div>
</div>
</div>
</template>
<style scoped>
.response-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 12px;
margin-top: 8px;
}
.thinking-state {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.thinking-dots {
display: flex;
gap: 4px;
}
.thinking-dots span {
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
animation: dot-bounce 1.4s ease-in-out infinite;
}
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
.thinking-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.response-body {
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
}
.response-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(139, 92, 246, 0.9);
background: rgba(139, 92, 246, 0.12);
padding: 2px 8px;
border-radius: 4px;
margin-bottom: 8px;
}
.response-text {
white-space: pre-wrap;
word-break: break-word;
}
.response-text :deep(strong) {
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
}
.fade-in {
animation: fade-in 0.3s ease-out;
}
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const props = withDefaults(defineProps<{
typeSpeed?: number
}>(), {
typeSpeed: 30
})
const emit = defineEmits<{
done: [text: string]
}>()
const PLACEHOLDER_TEXT = 'Necesito que revises el componente de autenticación en el módulo de usuarios. ' +
'Hay un problema con la validación de tokens JWT cuando el usuario tiene sesiones múltiples activas. ' +
'El token se invalida correctamente en el servidor pero el cliente sigue usando el token anterior ' +
'hasta que expira naturalmente. Quiero que implementes una verificación en tiempo real usando WebSocket ' +
'para notificar al cliente cuando su token ha sido revocado desde otra sesión.'
const displayedText = ref('')
let intervalId: number | null = null
let charIndex = 0
onMounted(() => {
intervalId = window.setInterval(() => {
if (charIndex < PLACEHOLDER_TEXT.length) {
displayedText.value += PLACEHOLDER_TEXT[charIndex]
charIndex++
} else {
if (intervalId) clearInterval(intervalId)
intervalId = null
emit('done', displayedText.value)
}
}, props.typeSpeed)
})
onBeforeUnmount(() => {
if (intervalId) clearInterval(intervalId)
})
</script>
<template>
<div class="transcript-card">
<div class="transcript-header">
<span class="rec-dot"></span>
<span class="rec-label">Transcribiendo...</span>
</div>
<div class="transcript-body">
<span class="transcript-text">{{ displayedText }}</span>
<span class="blink-cursor">|</span>
</div>
</div>
</template>
<style scoped>
.transcript-card {
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 10px;
padding: 12px;
margin-top: 8px;
}
.transcript-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
box-shadow: 0 0 8px #ef4444;
animation: rec-pulse 1s ease-in-out infinite;
}
.rec-label {
font-size: 11px;
font-weight: 600;
color: rgba(239, 68, 68, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.transcript-body {
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
}
.transcript-text {
white-space: pre-wrap;
}
.blink-cursor {
color: rgba(255, 255, 255, 0.7);
animation: cursor-blink 0.8s step-end infinite;
font-weight: 300;
}
@keyframes rec-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
50% { opacity: 0.4; box-shadow: 0 0 16px #ef4444; }
}
@keyframes cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>