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.
505 lines
13 KiB
Vue
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>
|