feat: Add AgentBar arc dock with per-agent terminal frame and voice modal

- Add ui.json configs for Main (purple) and Ejecutor (red) agents
- AgentBar: fused arc-shaped dock at bottom with dynamic glow
- Quick press opens styled terminal frame mockup
- Hold opens voice modal with Web Speech API streaming transcription
- Responsive: full-width mobile, max-width on tablet/desktop/4K
- Agents API: serve uiConfig from ui.json in agent directories
- Agents page: route, store, toolbar integration
This commit is contained in:
2026-02-15 02:58:11 -06:00
parent 9f9f335439
commit e9689d6ea8
12 changed files with 1698 additions and 31 deletions

View File

@@ -0,0 +1,720 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
// Web Speech API types
interface SpeechRecognitionEvent extends Event {
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
start(): void
stop(): void
abort(): void
}
interface UiConfig {
label: string
shortLabel: string
color: string
gradient: string
terminalBg: string
terminalBorder: string
enabled: boolean
}
interface Agent {
id: string
name: string
directory: string
uiConfig: UiConfig | null
}
const agents = ref<Agent[]>([])
const loading = ref(true)
// Terminal frame state
const terminalAgent = ref<Agent | null>(null)
const showTerminal = ref(false)
// Voice modal state
const voiceAgent = ref<Agent | null>(null)
const showVoice = ref(false)
const isRecording = ref(false)
const transcript = ref('')
const interimTranscript = ref('')
let recognition: SpeechRecognition | null = null
// Hold detection
let holdTimer: number | null = null
let holdTriggered = false
const HOLD_THRESHOLD = 400
const enabledAgents = computed(() =>
agents.value.filter(a => a.uiConfig?.enabled)
)
// Dynamic glow based on agent colors
const barGlowStyle = computed(() => {
const list = enabledAgents.value
if (!list.length) return {}
const glows = list.map(a => {
const c = a.uiConfig?.color || '#6366f1'
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
})
return {
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
}
})
async function fetchAgents() {
try {
const res = await fetch('/api/agents')
if (!res.ok) return
const data: Agent[] = await res.json()
agents.value = data
} catch {
// silent
} finally {
loading.value = false
}
}
// --- Terminal Frame ---
function openTerminal(agent: Agent) {
terminalAgent.value = agent
showTerminal.value = true
}
function closeTerminal() {
showTerminal.value = false
terminalAgent.value = null
}
// --- Voice Modal ---
function openVoice(agent: Agent) {
voiceAgent.value = agent
showVoice.value = true
startRecognition()
}
function closeVoice() {
stopRecognition()
showVoice.value = false
voiceAgent.value = null
transcript.value = ''
interimTranscript.value = ''
}
function initSpeechRecognition(): SpeechRecognition | null {
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SR) return null
const rec = new SR()
rec.continuous = true
rec.interimResults = true
rec.lang = 'es-419'
rec.onresult = (event: SpeechRecognitionEvent) => {
let interim = ''
let final = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
if (!result || !result[0]) continue
if (result.isFinal) {
final += result[0].transcript + ' '
} else {
interim += result[0].transcript
}
}
if (final) transcript.value += final
interimTranscript.value = interim
}
rec.onerror = () => {
isRecording.value = false
}
rec.onend = () => {
if (isRecording.value) {
try { rec.start() } catch { /* ignore */ }
}
}
return rec
}
function startRecognition() {
if (!recognition) recognition = initSpeechRecognition()
if (!recognition) return
transcript.value = ''
interimTranscript.value = ''
try {
recognition.start()
isRecording.value = true
} catch { /* ignore */ }
}
function stopRecognition() {
if (recognition) {
isRecording.value = false
try { recognition.stop() } catch { /* ignore */ }
}
}
// --- Pill interaction ---
function handlePointerDown(agent: Agent) {
holdTriggered = false
holdTimer = window.setTimeout(() => {
holdTriggered = true
openVoice(agent)
}, HOLD_THRESHOLD)
}
function handlePointerUp(agent: Agent) {
if (holdTimer) {
clearTimeout(holdTimer)
holdTimer = null
}
if (!holdTriggered) {
openTerminal(agent)
}
}
function handlePointerLeave() {
if (holdTimer) {
clearTimeout(holdTimer)
holdTimer = null
}
}
// Responsive
const isMobile = ref(window.innerWidth < 768)
function onResize() {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
fetchAgents()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
stopRecognition()
recognition = null
})
</script>
<template>
<!-- Agent Bar arc dock -->
<div v-if="enabledAgents.length" class="agent-bar">
<div class="agent-bar-inner" :style="barGlowStyle">
<button
v-for="agent in enabledAgents"
:key="agent.id"
class="agent-pill"
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
@pointerdown.prevent="handlePointerDown(agent)"
@pointerup="handlePointerUp(agent)"
@pointerleave="handlePointerLeave"
@contextmenu.prevent
>
<span class="pill-label">
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
</span>
</button>
</div>
</div>
<!-- Terminal Frame Modal -->
<Teleport to="body">
<Transition name="frame-fade">
<div v-if="showTerminal && terminalAgent" class="terminal-overlay" @click.self="closeTerminal">
<div
class="terminal-frame"
:style="{
'--term-bg': terminalAgent.uiConfig?.terminalBg || '#0f0a1a',
'--term-border': terminalAgent.uiConfig?.terminalBorder || '#6366f1',
'--term-color': terminalAgent.uiConfig?.color || '#6366f1'
}"
>
<div class="term-titlebar">
<div class="term-title-left">
<span class="term-dot red"></span>
<span class="term-dot yellow"></span>
<span class="term-dot green"></span>
<span class="term-name">{{ terminalAgent.uiConfig?.label || terminalAgent.name }}</span>
</div>
<button class="term-close" @click="closeTerminal">
<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>
<div class="term-body">
<div class="term-cursor-line">
<span class="term-prompt" :style="{ color: terminalAgent.uiConfig?.color }">~$</span>
<span class="term-cursor">_</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Voice Modal -->
<Teleport to="body">
<Transition name="voice-fade">
<div v-if="showVoice && voiceAgent" class="voice-overlay" @click.self="closeVoice">
<div
class="voice-frame"
:style="{
'--voice-color': voiceAgent.uiConfig?.color || '#6366f1',
'--voice-gradient': voiceAgent.uiConfig?.gradient || 'linear-gradient(135deg, #6366f1, #8b5cf6)',
'--voice-bg': voiceAgent.uiConfig?.terminalBg || '#0f0a1a'
}"
>
<div class="voice-header">
<div class="voice-agent-badge" :style="{ background: voiceAgent.uiConfig?.gradient }">
{{ voiceAgent.uiConfig?.shortLabel }}
</div>
<span class="voice-title">Voice</span>
<div class="voice-rec-indicator" :class="{ active: isRecording }"></div>
<button class="voice-close" @click="closeVoice">
<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>
<div class="voice-body">
<div class="voice-transcript" :class="{ empty: !transcript && !interimTranscript }">
<span class="voice-final">{{ transcript }}</span>
<span class="voice-interim">{{ interimTranscript }}</span>
<span v-if="!transcript && !interimTranscript" class="voice-placeholder">
Listening...
</span>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ====================== AGENT BAR — ARC DOCK ====================== */
.agent-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9990;
display: flex;
justify-content: center;
pointer-events: none;
}
.agent-bar-inner {
pointer-events: auto;
display: flex;
width: 100%;
height: 50px;
overflow: hidden;
/* Arc: wide elliptical curve at top, flat bottom */
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
/* Top edge highlight */
border-top: 1px solid rgba(255, 255, 255, 0.15);
position: relative;
}
/* Sheen line following the arc */
.agent-bar-inner::after {
content: '';
position: absolute;
top: 0;
left: 15%;
right: 15%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
pointer-events: none;
z-index: 2;
}
.agent-pill {
flex: 1;
border: none;
color: #fff;
font-weight: 700;
font-size: 14px;
letter-spacing: 0.5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
transition: filter 0.15s ease, brightness 0.15s ease;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
-webkit-touch-callout: none;
position: relative;
/* Safe area for phones with gesture bar */
padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Divider between pills */
.agent-pill + .agent-pill {
border-left: 1px solid rgba(255, 255, 255, 0.18);
}
.agent-pill:hover {
filter: brightness(1.15);
}
.agent-pill:active {
filter: brightness(0.9);
}
.pill-label {
line-height: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* ---- Responsive: Tablet (768+) ---- */
@media (min-width: 768px) {
.agent-bar-inner {
max-width: 480px;
height: 56px;
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
}
.agent-pill {
font-size: 15px;
}
}
/* ---- Responsive: Desktop (1200+) ---- */
@media (min-width: 1200px) {
.agent-bar-inner {
max-width: 580px;
height: 62px;
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
}
.agent-pill {
font-size: 16px;
letter-spacing: 0.8px;
}
}
/* ---- Responsive: 4K / TV (2400+) ---- */
@media (min-width: 2400px) {
.agent-bar-inner {
max-width: 740px;
height: 76px;
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
}
.agent-pill {
font-size: 20px;
letter-spacing: 1px;
}
}
/* ====================== TERMINAL FRAME ====================== */
.terminal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10050;
}
.terminal-frame {
width: 640px;
max-width: 92vw;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--term-border);
box-shadow:
0 0 30px color-mix(in srgb, var(--term-border) 30%, transparent),
0 20px 60px rgba(0, 0, 0, 0.5);
}
.term-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 36px;
padding: 0 12px;
background: color-mix(in srgb, var(--term-bg) 80%, var(--term-border) 20%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.term-title-left {
display: flex;
align-items: center;
gap: 8px;
}
.term-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.term-dot.red { background: #ff5f57; }
.term-dot.yellow { background: #ffbd2e; }
.term-dot.green { background: #28c840; }
.term-name {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin-left: 4px;
font-family: system-ui, sans-serif;
}
.term-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.term-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.term-body {
min-height: 300px;
padding: 16px;
background: var(--term-bg);
font-family: 'Consolas', 'Monaco', 'Cascadia Code', monospace;
font-size: 14px;
}
.term-cursor-line {
display: flex;
align-items: center;
gap: 8px;
}
.term-prompt {
font-weight: 600;
}
.term-cursor {
color: rgba(255, 255, 255, 0.7);
animation: cursor-blink 1s step-end infinite;
}
@keyframes cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* ====================== VOICE MODAL ====================== */
.voice-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10050;
}
.voice-frame {
width: 400px;
max-width: 90vw;
border-radius: 16px;
overflow: hidden;
background: var(--voice-bg);
border: 1px solid color-mix(in srgb, var(--voice-color) 40%, transparent);
box-shadow:
0 0 40px color-mix(in srgb, var(--voice-color) 25%, transparent),
0 20px 60px rgba(0, 0, 0, 0.5);
}
.voice-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: color-mix(in srgb, var(--voice-bg) 70%, var(--voice-color) 30%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.voice-agent-badge {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.voice-title {
flex: 1;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
font-family: system-ui, sans-serif;
}
.voice-rec-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.2s;
}
.voice-rec-indicator.active {
background: #ef4444;
box-shadow: 0 0 8px #ef4444;
animation: rec-pulse 1s ease-in-out infinite;
}
@keyframes rec-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
50% { opacity: 0.5; box-shadow: 0 0 16px #ef4444; }
}
.voice-close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.voice-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.voice-body {
min-height: 160px;
max-height: 300px;
padding: 20px;
overflow-y: auto;
}
.voice-transcript {
font-size: 16px;
line-height: 1.7;
color: rgba(255, 255, 255, 0.9);
font-family: system-ui, sans-serif;
}
.voice-transcript.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
}
.voice-final {
color: rgba(255, 255, 255, 0.9);
}
.voice-interim {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.voice-placeholder {
color: rgba(255, 255, 255, 0.25);
font-size: 15px;
font-style: italic;
animation: placeholder-pulse 2s ease-in-out infinite;
}
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ====================== TRANSITIONS ====================== */
.frame-fade-enter-active,
.voice-fade-enter-active {
transition: opacity 0.2s ease;
}
.frame-fade-leave-active,
.voice-fade-leave-active {
transition: opacity 0.15s ease;
}
.frame-fade-enter-from,
.frame-fade-leave-to,
.voice-fade-enter-from,
.voice-fade-leave-to {
opacity: 0;
}
.frame-fade-enter-active .terminal-frame {
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.frame-fade-leave-active .terminal-frame {
animation: frame-scale-out 0.15s ease forwards;
}
.voice-fade-enter-active .voice-frame {
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.voice-fade-leave-active .voice-frame {
animation: frame-scale-out 0.15s ease forwards;
}
@keyframes frame-scale-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes frame-scale-out {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
</style>