Files
agent-ui/frontend/src/components/AgentBar.vue
josedario87 6633a61ee4 feat: Auto-open PromptBar on permission request and improve permission card UI
PromptBar now auto-opens when a permission request arrives and it's hidden.
Permission cards show rich contextual info: tool badge, description, code
blocks for Bash, diff preview for Edit, file paths for Write, and icons
on Allow/Deny buttons.
2026-02-16 01:40:08 -06:00

505 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, computed } from 'vue'
import { endpoints } from '../config/endpoints'
import type { Agent, AgentStatusState, ClaudeStatus } from '../types/agent'
import { useVoiceCapture } from '../composables/useVoiceCapture'
import { useCanvasStore } from '../stores/canvas'
import FloatBubble from './agent/FloatBubble.vue'
import PromptBar from './agent/PromptBar.vue'
import TranscriptCard from './agent/TranscriptCard.vue'
const agents = ref<Agent[]>([])
const loading = ref(true)
// Per-agent status tracking
const agentStatuses = reactive<Record<string, AgentStatusState>>({})
const agentTimers = new Map<string, Record<string, number>>()
// PromptBar state
const activeAgentId = ref<string | null>(null)
const activeAnchorRect = ref<DOMRect | null>(null)
const openInRecording = ref(false)
const promptBarRef = ref<InstanceType<typeof PromptBar> | null>(null)
const bubbleRefs = new Map<string, Element>()
function setBubbleRef(agentId: string, el: any) {
if (el?.$el) bubbleRefs.set(agentId, el.$el)
else if (el) bubbleRefs.set(agentId, el)
}
function autoOpenForAgent(agentId: string) {
if (activeAgentId.value === agentId) return // Already open
const bubbleEl = bubbleRefs.get(agentId)
if (!bubbleEl) return
activeAnchorRect.value = bubbleEl.getBoundingClientRect()
openInRecording.value = false
activeAgentId.value = agentId
}
const isRecordingActive = computed(() =>
promptBarRef.value?.isRecording ?? false
)
// Floating transcript state (long-press recording)
const canvasStore = useCanvasStore()
const floatingVoice = useVoiceCapture({
onNotification: (msg, type, dur) => canvasStore.showNotification(msg, type, dur)
})
const floatingAgentId = ref<string | null>(null)
const floatingAnchorRect = ref<DOMRect | null>(null)
const floatingAgent = computed(() =>
enabledAgents.value.find(a => a.id === floatingAgentId.value) || null
)
const isFloatingRecording = computed(() =>
!!floatingAgentId.value && floatingVoice.isRecording.value
)
const floatingStyle = computed(() => {
if (!floatingAnchorRect.value) return {}
const rect = floatingAnchorRect.value
const bubbleCenterX = rect.left + rect.width / 2
const bottomOffset = window.innerHeight - rect.top + 12
const panelWidth = 320
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`,
zIndex: 10000
}
})
const enabledAgents = computed(() =>
agents.value.filter(a => a.uiConfig?.enabled)
)
const activeAgent = computed(() =>
enabledAgents.value.find(a => a.id === activeAgentId.value) || null
)
// --- Status tracking helpers ---
function getAgentStatus(agentId: string): AgentStatusState {
if (!agentStatuses[agentId]) {
agentStatuses[agentId] = {
isProcessing: false,
isReading: false,
isWriting: false,
awaitingPermission: false,
showToolFlash: false,
showNotification: false,
currentTool: null
}
}
return agentStatuses[agentId]
}
function getTimers(agentId: string): Record<string, number> {
if (!agentTimers.has(agentId)) {
agentTimers.set(agentId, {})
}
return agentTimers.get(agentId)!
}
function clearAgentTimer(agentId: string, key: string) {
const timers = getTimers(agentId)
if (timers[key]) {
clearTimeout(timers[key])
delete timers[key]
}
}
function setAgentTimer(agentId: string, key: string, fn: () => void, ms: number) {
clearAgentTimer(agentId, key)
const timers = getTimers(agentId)
timers[key] = window.setTimeout(fn, ms)
}
function triggerToolFlash(agentId: string) {
const s = getAgentStatus(agentId)
s.showToolFlash = true
setAgentTimer(agentId, 'toolFlash', () => {
s.showToolFlash = false
}, 500)
}
// --- WebSocket ---
let statusWs: WebSocket | null = null
let reconnectTimeout: number | null = null
function connectStatusWs() {
if (statusWs?.readyState === WebSocket.OPEN) return
console.log('[AgentBar] Connecting to', endpoints.claudeStatus)
statusWs = new WebSocket(endpoints.claudeStatus)
statusWs.onopen = () => {
console.log('[AgentBar] WebSocket OPEN')
}
statusWs.onerror = (err) => {
console.error('[AgentBar] WebSocket ERROR', err)
}
statusWs.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
console.log('[AgentBar] WS message:', msg.type, msg.status || '', msg.agent || '')
if (msg.type !== 'claude-status') return
const status = msg.status as ClaudeStatus
const agentName = (msg.agent || 'main') as string
const tool = msg.tool || null
console.log(`[AgentBar] Status: ${status}, agent: ${agentName}, tool: ${tool}`)
const agent = enabledAgents.value.find(a =>
a.id.toLowerCase() === agentName.toLowerCase() ||
a.name.toLowerCase() === agentName.toLowerCase() ||
a.uiConfig?.label.toLowerCase() === agentName.toLowerCase()
)
if (!agent) {
console.log(`[AgentBar] No matching enabled agent for "${agentName}", enabled:`, enabledAgents.value.map(a => a.id))
return
}
console.log(`[AgentBar] Matched agent: ${agent.id}, applying status: ${status}`)
const s = getAgentStatus(agent.id)
s.currentTool = tool
switch (status) {
case 'processing':
case 'thinking':
s.isProcessing = true
setAgentTimer(agent.id, 'processing', () => {
s.isProcessing = false
}, 120000)
break
case 'idle':
s.isProcessing = false
s.isReading = false
s.isWriting = false
s.awaitingPermission = false
clearAgentTimer(agent.id, 'processing')
break
case 'permissionRequest':
s.awaitingPermission = true
// Auto-open PromptBar if not already open for this agent
autoOpenForAgent(agent.id)
break
case 'reading':
s.isReading = true
triggerToolFlash(agent.id)
break
case 'writing':
s.isWriting = true
triggerToolFlash(agent.id)
break
case 'toolUse':
triggerToolFlash(agent.id)
break
case 'toolDone':
s.isReading = false
s.isWriting = false
s.awaitingPermission = false
break
case 'notification':
s.showNotification = true
setAgentTimer(agent.id, 'notification', () => {
s.showNotification = false
}, 2000)
break
}
} catch { /* ignore */ }
}
statusWs.onclose = () => {
reconnectTimeout = window.setTimeout(connectStatusWs, 2000)
}
}
// --- Data fetching ---
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
}
}
// --- Bubble interaction ---
function openPromptBar(agent: Agent, el: HTMLElement, recording: boolean) {
if (activeAgentId.value === agent.id && !recording) {
activeAgentId.value = null
activeAnchorRect.value = null
openInRecording.value = false
return
}
activeAnchorRect.value = el.getBoundingClientRect()
openInRecording.value = recording
activeAgentId.value = agent.id
}
function handleBubbleClick(agent: Agent, event: MouseEvent) {
openPromptBar(agent, event.currentTarget as HTMLElement, false)
}
async function handleBubbleHold(agent: Agent, el: HTMLElement) {
// Close PromptBar if open
if (activeAgentId.value) {
handlePromptClose()
}
// Open floating transcript
floatingAnchorRect.value = el.getBoundingClientRect()
floatingAgentId.value = agent.id
await floatingVoice.init()
floatingVoice.clearTranscript()
floatingVoice.startRecording()
}
function handleBubbleHoldRelease() {
if (!floatingAgentId.value || !floatingVoice.isRecording.value) return
// Buffer 500ms for trailing words, then stop and emit done via TranscriptCard
setTimeout(() => {
if (floatingVoice.isRecording.value) {
floatingVoice.stopRecording()
// Wait for final Whisper result if needed
const delay = floatingVoice.voiceMode.value === 'whisper' ? 800 : 200
setTimeout(() => {
const text = floatingVoice.transcript.value.trim()
closeFloating()
if (text) {
console.log(`[AgentBar] Voice submit to ${floatingAgent.value?.id}:`, text)
}
}, delay)
}
}, 500)
}
function handleFloatingDone(text: string) {
closeFloating()
if (text.trim()) {
console.log(`[AgentBar] Voice submit to ${floatingAgent.value?.id}:`, text)
}
}
function closeFloating() {
if (floatingVoice.isRecording.value) {
floatingVoice.stopRecording()
}
floatingVoice.cleanup()
floatingAgentId.value = null
floatingAnchorRect.value = null
}
function handleFloatingClose() {
closeFloating()
}
function handlePromptClose() {
activeAgentId.value = null
activeAnchorRect.value = null
openInRecording.value = false
}
function handlePromptSubmit(text: string) {
console.log(`[AgentBar] Submit to ${activeAgentId.value}:`, text)
}
// --- Lifecycle ---
onMounted(() => {
fetchAgents()
connectStatusWs()
})
onBeforeUnmount(() => {
statusWs?.close()
floatingVoice.cleanup()
if (reconnectTimeout) clearTimeout(reconnectTimeout)
for (const [, timers] of agentTimers) {
for (const key of Object.keys(timers)) {
clearTimeout(timers[key])
}
}
agentTimers.clear()
})
</script>
<template>
<div v-if="enabledAgents.length" class="agent-bubbles">
<FloatBubble
v-for="agent in enabledAgents"
:key="agent.id"
:ref="(el: any) => setBubbleRef(agent.id, el)"
:agent="agent"
:status="agentStatuses[agent.id]"
:recording="(activeAgentId === agent.id && isRecordingActive) || (floatingAgentId === agent.id && isFloatingRecording)"
@click="handleBubbleClick(agent, $event)"
@hold="handleBubbleHold(agent, $event)"
@holdrelease="handleBubbleHoldRelease"
/>
</div>
<PromptBar
v-if="activeAgent"
:key="activeAgent.id"
ref="promptBarRef"
:agent="activeAgent"
:anchor-rect="activeAnchorRect"
:visible="!!activeAgentId"
:start-recording="openInRecording"
@close="handlePromptClose"
@submit="handlePromptSubmit"
/>
<!-- Floating transcript (long-press recording) -->
<Teleport to="body">
<Transition name="float-transcript">
<div v-if="floatingAgentId && floatingAnchorRect" class="float-transcript-backdrop" @click.self="handleFloatingClose">
<div class="float-transcript-panel" :style="floatingStyle">
<div class="ft-header">
<div
v-if="floatingAgent"
class="ft-badge"
:style="{ background: floatingAgent.uiConfig?.gradient || floatingAgent.uiConfig?.color }"
>
{{ floatingAgent.uiConfig?.shortLabel }}
</div>
<span class="ft-label">{{ floatingAgent?.uiConfig?.label || floatingAgent?.name }}</span>
</div>
<TranscriptCard :voice="floatingVoice" @done="handleFloatingDone" />
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.agent-bubbles {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 14px;
z-index: 9998;
pointer-events: none;
}
/* Floating transcript */
.float-transcript-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
}
.float-transcript-panel {
background: rgba(15, 10, 26, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 10px 12px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
transform-origin: bottom center;
}
.ft-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.ft-badge {
width: 22px;
height: 22px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.ft-label {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
font-family: system-ui, sans-serif;
}
/* Transition */
.float-transcript-enter-active {
transition: opacity 0.2s ease;
}
.float-transcript-enter-active .float-transcript-panel {
animation: ft-enter 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.float-transcript-enter-from {
opacity: 0;
}
.float-transcript-leave-active {
transition: opacity 0.15s ease;
}
.float-transcript-leave-active .float-transcript-panel {
animation: ft-leave 0.15s ease both;
}
.float-transcript-leave-to {
opacity: 0;
}
@keyframes ft-enter {
0% { opacity: 0; transform: translateY(10px) scale(0.9); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes ft-leave {
0% { opacity: 1; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(8px) scale(0.95); }
}
@media (max-width: 768px) {
.agent-bubbles {
bottom: 80px;
gap: 12px;
}
.float-transcript-panel {
left: 8px !important;
right: 8px;
width: auto !important;
}
}
</style>