feat: collapse-all button in float transcript titlebar

Add titlebar button to collapse all conversation sections except the
last user message. Special user messages (interrupted, meta/continue)
are treated as section children rather than section leaders.
This commit is contained in:
2026-02-20 12:53:18 -06:00
parent 220d595568
commit 779e32b283
17 changed files with 69 additions and 7080 deletions

View File

@@ -1,504 +0,0 @@
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -606,6 +606,19 @@ onBeforeUnmount(() => {
<rect x="1" y="1" width="8" height="8" fill="currentColor"/> <rect x="1" y="1" width="8" height="8" fill="currentColor"/>
</svg> </svg>
</button> </button>
<button
@click.stop="chatRef?.collapseAllExceptLast()"
:class="{ active: chatRef?.allCollapsed }"
class="collapse-all-btn"
title="Collapse all except last"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
<template v-else>
<polyline points="18 15 12 9 6 15"/>
</template>
</svg>
</button>
<button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages"> <button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/> <polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
@@ -963,6 +976,22 @@ onBeforeUnmount(() => {
color: #c4b5fd; color: #c4b5fd;
} }
.window-controls .collapse-all-btn {
color: #2dd4bf;
}
.window-controls .collapse-all-btn:hover {
color: #5eead4;
background: rgba(45, 212, 191, 0.15);
border-color: rgba(45, 212, 191, 0.25);
}
.window-controls .collapse-all-btn.active {
background: rgba(45, 212, 191, 0.2);
border-color: rgba(45, 212, 191, 0.3);
color: #5eead4;
}
.window-controls .size-btn { .window-controls .size-btn {
color: #0ea5e9; color: #0ea5e9;
} }

View File

@@ -1,514 +0,0 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import type { Agent } from '../../types/agent'
import type { AgentTerminal as AgentTerminalType } from '../../composables/useAgentTerminal'
import TerminalNavButtons from '../TerminalNavButtons.vue'
const props = defineProps<{
modelValue: boolean
agent: Agent
agentTerminal: AgentTerminalType
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const renderer = props.agentTerminal.renderer
const terminalState = props.agentTerminal.terminalState
const connected = props.agentTerminal.connected
const agentRunning = props.agentTerminal.agentRunning
const startAgent = props.agentTerminal.startAgent
const stopAgent = props.agentTerminal.stopAgent
const showNavButtons = ref(false)
// Local ref for the xterm container - syncs to composable's containerRef
const terminalContainer = ref<HTMLElement | null>(null)
watch(terminalContainer, (el) => {
props.agentTerminal.containerRef.value = el
})
// Drag state
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 560, h: 340 })
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
const windowRef = ref<HTMLElement | null>(null)
// Style
const agentColor = computed(() => props.agent.uiConfig?.color || '#6366f1')
const agentBg = computed(() => props.agent.uiConfig?.terminalBg || '#0f0a1a')
const agentBorder = computed(() => props.agent.uiConfig?.terminalBorder || '#6366f1')
const statusDotClass = computed(() => {
switch (terminalState.value) {
case 'ready': return 'on'
case 'connecting':
case 'agent-starting': return 'wait'
case 'crashed': return 'error'
default: return ''
}
})
const terminalStyle = computed((): Record<string, string> => {
if (!hasCustomPosition.value) {
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '80px',
right: '16px'
}
}
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
// ── Drag ──
function startDrag(e: MouseEvent | TouchEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
if (e instanceof TouchEvent) e.preventDefault()
isDragging.value = true
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const rect = windowRef.value?.getBoundingClientRect()
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return
if (e instanceof TouchEvent) e.preventDefault()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const w = windowRef.value?.offsetWidth || 560
const h = windowRef.value?.offsetHeight || 340
position.value = {
x: Math.max(-w * 0.75, Math.min(clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
y: Math.max(-h * 0.75, Math.min(clientY - dragOffset.value.y, window.innerHeight - h * 0.25))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// ── Resize ──
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => renderer.fit())
}
// ── Actions ──
function close() {
isOpen.value = false
}
function clearBuffer() {
renderer.reset()
}
function handleRestart() {
startAgent(true)
}
function toggleNavButtons() {
showNavButtons.value = !showNavButtons.value
}
// Nav button actions - send commands through the agent terminal
function navRunClaude() {
props.agentTerminal.sendInput('claude')
}
function navRunClaudeContinue() {
props.agentTerminal.sendInput('claude --continue')
}
function navRunClaudeResume() {
props.agentTerminal.sendInput('claude --resume')
}
function navRefresh() {
renderer.fit()
}
function navSendKey(key: string) {
const keyMap: Record<string, string> = {
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
}
const data = keyMap[key]
if (data) props.agentTerminal.sendRaw(data)
}
function navScroll(direction: 'up' | 'down' | 'end') {
if (direction === 'up') renderer.scrollLines(-10)
else if (direction === 'down') renderer.scrollLines(10)
else renderer.scrollToBottom()
}
// ── Watch open/close to init terminal ──
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
// Initialize the renderer if not already done (container ref is now set)
if (!renderer.isReady.value) {
renderer.init()
}
// Wait for CSS transition then fit (+ focus only on desktop)
setTimeout(() => {
renderer.fit()
renderer.terminal.value?.refresh(0, renderer.terminal.value.rows - 1)
if (window.innerWidth > 1024) {
renderer.focus()
}
}, 150)
})
} else {
renderer.blur()
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<template>
<Teleport to="body">
<Transition name="at-slide">
<div
v-show="isOpen"
ref="windowRef"
class="agent-terminal"
:class="{ dragging: isDragging, resizing: isResizing }"
:style="terminalStyle"
>
<div class="at-glass" :style="{ borderColor: agentBorder + '40' }">
<!-- Titlebar -->
<div class="at-titlebar" @mousedown="startDrag" @touchstart="startDrag">
<div class="at-left">
<div
class="at-badge"
:style="{ background: agent.uiConfig?.gradient || agentColor }"
>{{ agent.uiConfig?.shortLabel || agent.id[0]?.toUpperCase() }}</div>
<span class="at-name">{{ agent.uiConfig?.label || agent.name }}</span>
<i class="at-dot" :class="statusDotClass"></i>
<span v-if="terminalState === 'agent-starting'" class="at-status-text">Starting...</span>
<span v-else-if="terminalState === 'connecting'" class="at-status-text">Connecting...</span>
<span v-else-if="terminalState === 'crashed'" class="at-status-text crashed">Crashed</span>
</div>
<div class="window-controls">
<button
class="wc-btn nav-toggle"
:class="{ active: showNavButtons }"
title="Toggle navigation"
@click.stop="toggleNavButtons"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<button class="wc-btn x" title="Close" @click.stop="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>
</div>
<!-- Terminal content -->
<div class="at-content" :style="{ background: agentBg }">
<div ref="terminalContainer" class="at-term"></div>
<!-- Overlay states -->
<div v-if="terminalState === 'off'" class="at-overlay" @click="agentTerminal.connect()">
<div class="at-overlay-msg">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
<span>Agent offline</span>
<small>Click to connect</small>
</div>
</div>
<div v-else-if="terminalState === 'connecting'" class="at-overlay connecting">
<div class="at-overlay-msg">
<div class="at-spinner"></div>
<span>Connecting...</span>
</div>
</div>
</div>
<!-- Resize handle -->
<div class="at-resize" @mousedown="startResize"></div>
</div>
<!-- Nav buttons bar (outside terminal, hangs from bottom) -->
<TerminalNavButtons
v-if="showNavButtons"
class="at-nav-popup"
:show-start="terminalState === 'crashed' || terminalState === 'off'"
:show-restart="agentRunning"
@request-token="agentTerminal.sendInput('genera token usando tu mcp')"
@run-claude="navRunClaude"
@run-claude-continue="navRunClaudeContinue"
@run-claude-resume="navRunClaudeResume"
@clear-buffer="clearBuffer"
@refresh="navRefresh"
@restart="handleRestart"
@send-key="navSendKey"
@scroll="navScroll"
/>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.agent-terminal {
position: fixed;
min-width: 400px;
min-height: 250px;
z-index: 10001;
}
.at-glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(200, 215, 235, 0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 0 0 1px rgba(80, 120, 180, 0.25), 0 6px 24px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
overflow: hidden;
}
.at-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 24px;
padding: 0 4px 0 6px;
background: rgba(255, 255, 255, 0.25);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
cursor: grab;
user-select: none;
}
.at-titlebar { touch-action: none; }
.agent-terminal.dragging .at-titlebar { cursor: grabbing; }
.at-left {
display: flex;
align-items: center;
gap: 6px;
font: 500 10px/1 system-ui, sans-serif;
color: #222;
}
.at-badge {
width: 16px;
height: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 8px;
font-weight: 700;
flex-shrink: 0;
}
.at-name {
font-weight: 600;
font-size: 11px;
color: #333;
}
.at-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #999;
flex-shrink: 0;
}
.at-dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.at-dot.wait { background: #a80; animation: at-pulse 0.8s infinite; }
.at-dot.error { background: #e44; box-shadow: 0 0 4px #e44; }
.at-status-text {
font-size: 9px;
color: #666;
}
.at-status-text.crashed { color: #c33; }
.window-controls { display: flex; gap: 1px; }
.wc-btn {
width: 20px;
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: 2px;
color: #333;
cursor: pointer;
}
.wc-btn:hover { background: rgba(255, 255, 255, 0.5); }
.wc-btn.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
.wc-btn.nav-toggle { width: 44px; }
.wc-btn.nav-toggle.active { background: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.4); color: #818cf8; }
.wc-btn.nav-toggle:hover { background: rgba(99, 102, 241, 0.2); color: #818cf8; }
.at-content {
flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden;
position: relative;
}
.at-term { width: 100%; height: 100%; }
.at-term :deep(.xterm) { height: 100%; padding: 2px; }
.at-term :deep(.xterm-viewport) {
overflow-y: auto !important;
scrollbar-width: thin;
}
.at-term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0, 0, 0, 0.2); }
.at-term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.at-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
cursor: pointer;
}
.at-overlay.connecting { cursor: wait; }
.at-overlay-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff;
text-align: center;
}
.at-overlay-msg svg { color: #ef4444; opacity: 0.8; }
.at-overlay-msg span { font-size: 13px; font-weight: 500; }
.at-overlay-msg small { font-size: 10px; color: #888; }
.at-spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: at-spin 0.8s linear infinite;
}
.at-resize {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 100%);
border-radius: 0 0 5px 0;
}
.at-resize:hover { background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.5) 50%, rgba(255, 255, 255, 0.2) 100%); }
.agent-terminal.resizing { user-select: none; }
.agent-terminal.resizing .at-term { pointer-events: none; }
.at-nav-popup {
position: absolute;
left: 0;
right: 0;
top: 100%;
border-radius: 0 0 6px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: none;
backdrop-filter: blur(14px) saturate(1.3);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1;
}
.at-slide-enter-active, .at-slide-leave-active { transition: all 0.15s ease; }
.at-slide-enter-from, .at-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
@keyframes at-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes at-spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -1,225 +0,0 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{
placeholder?: string
recording?: boolean
historyActive?: boolean
settingsActive?: boolean
terminalActive?: boolean
disabled?: boolean
autofocus?: boolean
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
}>()
const emit = defineEmits<{
submit: [text: string]
mic: []
'toggle-history': []
'toggle-settings': []
'toggle-terminal': []
}>()
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()
}
const isMobile = () => window.innerWidth < 480
watch(() => props.autofocus, async (v) => {
if (v && !isMobile()) {
await nextTick()
focus()
}
}, { immediate: true })
defineExpose({ focus })
</script>
<template>
<div class="chat-input-row">
<i v-if="statusDot" class="ci-status-dot" :class="statusDot"></i>
<input
ref="inputEl"
v-model="inputText"
class="chat-input"
type="text"
:placeholder="placeholder || 'Escribe un mensaje...'"
:disabled="disabled"
@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>
<!-- History button hidden for now
<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>
-->
<!-- Settings and Terminal buttons moved to PromptBar header
<button
class="ci-btn ci-settings"
:class="{ active: settingsActive }"
title="Voice settings"
@click="emit('toggle-settings')"
>
<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="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button
class="ci-btn ci-terminal"
:class="{ active: terminalActive }"
title="Terminal"
@click="emit('toggle-terminal')"
>
<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">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" y1="19" x2="20" y2="19"/>
</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,
.ci-settings.active,
.ci-terminal.active {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.7);
}
.ci-terminal:hover {
background: rgba(16, 185, 129, 0.15);
border-color: rgba(16, 185, 129, 0.25);
color: rgba(16, 185, 129, 0.9);
}
.ci-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ci-status-dot.green { background: #22c55e; box-shadow: 0 0 4px #22c55e; }
.ci-status-dot.yellow { background: #eab308; box-shadow: 0 0 4px #eab308; animation: ci-pulse 1s infinite; }
.ci-status-dot.red { background: #ef4444; box-shadow: 0 0 4px #ef4444; }
.ci-status-dot.gray { background: #6b7280; }
.chat-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes ci-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@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

@@ -1,404 +0,0 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import type { Agent, TranscriptSession, TranscriptMessage } from '../../types/agent'
defineProps<{
agent: Agent
}>()
const sessions = ref<TranscriptSession[]>([])
const activeSessionId = ref<string | null>(null)
const messages = ref<TranscriptMessage[]>([])
const stats = ref<{
model: string
duration: number
toolCallCount: number
totalInput: number
totalOutput: number
} | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// ── Helpers ──
function formatSessionTime(iso: string): string {
if (!iso) return '--:--'
const d = new Date(iso)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
}
function formatDuration(ms: number): string {
if (!ms || ms <= 0) return '0s'
const totalSec = Math.floor(ms / 1000)
const m = Math.floor(totalSec / 60)
const s = totalSec % 60
if (m === 0) return `${s}s`
return `${m}m ${s}s`
}
function formatTokens(n: number): string {
if (!n || n <= 0) return '0'
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
return String(n)
}
function shortModel(model: string): string {
if (!model) return '?'
if (model.includes('opus')) return 'opus-4'
if (model.includes('sonnet')) return 'sonnet-4'
if (model.includes('haiku')) return 'haiku-4'
// Fallback: last segment
const parts = model.split('-')
return parts.slice(-2).join('-')
}
function truncateContent(text: string, max = 200): string {
if (!text || text.length <= max) return text || ''
return text.slice(0, max) + '...'
}
// ── Fetch helpers ──
function getBaseUrl(sessionId: string): string {
const firstSession = sessions.value[0]
if (firstSession && sessionId === firstSession.id) {
return '/api/transcript/latest'
}
return `/api/transcript/${sessionId}`
}
async function fetchSessions() {
try {
const res = await fetch('/api/transcript/sessions')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
sessions.value = await res.json()
} catch (e: any) {
error.value = `Error cargando sesiones: ${e.message}`
}
}
async function fetchSessionData(sessionId: string) {
loading.value = true
error.value = null
try {
const base = getBaseUrl(sessionId)
const [msgRes, statsRes, tokensRes] = await Promise.all([
fetch(`${base}?section=messages`),
fetch(`${base}?section=stats`),
fetch(`${base}?section=tokens`)
])
if (!msgRes.ok || !statsRes.ok || !tokensRes.ok) {
throw new Error('Error fetching session data')
}
const [msgData, statsData, tokensData] = await Promise.all([
msgRes.json(),
statsRes.json(),
tokensRes.json()
])
messages.value = msgData.messages || []
stats.value = {
model: statsData.model || '',
duration: statsData.duration || 0,
toolCallCount: statsData.stats?.toolCallCount || 0,
totalInput: tokensData.tokens?.totalInput || 0,
totalOutput: tokensData.tokens?.totalOutput || 0
}
} catch (e: any) {
error.value = `Error cargando datos: ${e.message}`
messages.value = []
stats.value = null
} finally {
loading.value = false
}
}
// ── Lifecycle ──
watch(activeSessionId, (id) => {
if (id) fetchSessionData(id)
})
onMounted(async () => {
await fetchSessions()
if (sessions.value.length > 0) {
activeSessionId.value = sessions.value[0].id
}
})
</script>
<template>
<div class="conversation-history">
<!-- Header -->
<div class="history-header">
<span class="history-title">Historial</span>
<span class="history-count">{{ messages.length }}</span>
</div>
<!-- Session Pills -->
<div v-if="sessions.length > 1" class="session-pills">
<button
v-for="session in sessions"
:key="session.id"
class="session-pill"
:class="{ active: session.id === activeSessionId }"
@click="activeSessionId = session.id"
>
{{ formatSessionTime(session.startTime) }}
<span class="pill-count">({{ session.messageCount }})</span>
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="history-loading">
<span class="loading-dots">...</span>
</div>
<!-- Error -->
<div v-else-if="error" class="history-error">{{ error }}</div>
<!-- Empty -->
<div v-else-if="messages.length === 0 && !loading" class="history-empty">
Sin mensajes en esta sesión
</div>
<!-- Message List -->
<div v-else class="history-list">
<div
v-for="msg in messages"
:key="msg.uuid"
class="history-entry"
:class="msg.role === 'assistant' ? 'agent' : 'user'"
>
<div class="entry-meta">
<span
class="role-badge"
:class="msg.role === 'assistant' ? 'agent' : 'user'"
>
{{ msg.role === 'user' ? 'Tú' : agent.uiConfig?.shortLabel || 'AG' }}
</span>
<span class="entry-time">{{ formatSessionTime(msg.timestamp) }}</span>
<span v-if="msg.toolCalls && msg.toolCalls.length > 0" class="tool-badge">
{{ msg.toolCalls.length }} tools
</span>
</div>
<div class="entry-content">{{ truncateContent(msg.content) }}</div>
</div>
</div>
<!-- Stats Bar -->
<div v-if="stats && !loading && messages.length > 0" class="stats-bar">
<span>{{ shortModel(stats.model) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ formatDuration(stats.duration) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ formatTokens(stats.totalInput + stats.totalOutput) }}</span>
<span class="stats-sep">&middot;</span>
<span>{{ stats.toolCallCount }} tools</span>
</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;
}
/* Session Pills */
.session-pills {
display: flex;
gap: 6px;
overflow-x: auto;
padding-bottom: 8px;
margin-bottom: 8px;
scrollbar-width: none;
}
.session-pills::-webkit-scrollbar {
display: none;
}
.session-pill {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 3px 8px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.session-pill:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.session-pill.active {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
color: rgba(139, 92, 246, 0.9);
}
.pill-count {
opacity: 0.6;
margin-left: 2px;
}
/* Message List */
.history-list {
max-height: 220px;
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);
}
.tool-badge {
font-size: 9px;
font-weight: 600;
padding: 1px 5px;
border-radius: 4px;
color: rgba(245, 158, 11, 0.9);
background: rgba(245, 158, 11, 0.12);
}
.entry-content {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.7);
}
/* Stats Bar */
.stats-bar {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
font-family: monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
}
.stats-sep {
opacity: 0.4;
}
/* States */
.history-loading {
padding: 16px 0;
text-align: center;
color: rgba(255, 255, 255, 0.3);
font-size: 12px;
}
.loading-dots {
animation: pulse 1s ease-in-out infinite;
}
.history-error {
padding: 12px 0;
text-align: center;
color: rgba(239, 68, 68, 0.7);
font-size: 11px;
}
.history-empty {
padding: 16px 0;
text-align: center;
color: rgba(255, 255, 255, 0.25);
font-size: 11px;
font-style: italic;
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
</style>

View File

@@ -1,590 +0,0 @@
<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]
holdrelease: []
}>()
const HOLD_MS = 200
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('holdrelease')
} else {
emit('click', e as unknown as MouseEvent)
}
}
function onPointerCancel() {
clearHold()
if (didHold) {
emit('holdrelease')
}
}
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"
@touchstart.prevent
@contextmenu.prevent
@selectstart.prevent
@dragstart.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;
-webkit-tap-highlight-color: transparent;
touch-action: none;
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; }
}
/* Prevent touch selection on all children */
.agent-bubble * {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
pointer-events: none;
}
/* ====================== 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

@@ -1,351 +0,0 @@
<script setup lang="ts">
import type { VoiceCapture } from '../../composables/useVoiceCapture'
interface SessionInfo {
id: string
startTime: string
messageCount: number
model: string
}
const props = defineProps<{
voice: VoiceCapture
sessions?: SessionInfo[]
activeSessionId?: string | null
}>()
const emit = defineEmits<{
close: []
'select-session': [sessionId: string]
}>()
function handleMicSelect(e: Event) {
const target = e.target as HTMLSelectElement
props.voice.selectMicrophone(target.value)
}
function handleSessionSelect(e: Event) {
const target = e.target as HTMLSelectElement
emit('select-session', target.value)
}
function formatSessionLabel(s: SessionInfo): string {
if (!s.startTime) return `${s.id.slice(0, 8)}... (${s.messageCount} msgs)`
const d = new Date(s.startTime)
const date = d.toLocaleDateString('es', { month: 'short', day: 'numeric' })
const time = d.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
return `${date} ${time} · ${s.messageCount} msgs`
}
</script>
<template>
<div class="input-settings">
<div class="is-header">
<span class="is-title">Voice Settings</span>
<button class="is-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>
<!-- Session Selector -->
<div v-if="sessions && sessions.length > 0" class="is-section">
<label class="is-label">Session</label>
<select
class="is-select"
:value="activeSessionId || ''"
@change="handleSessionSelect"
>
<option
v-for="session in sessions"
:key="session.id"
:value="session.id"
>
{{ formatSessionLabel(session) }}
</option>
</select>
</div>
<!-- Whisper Status -->
<div class="is-section">
<label class="is-label">Mode</label>
<div class="is-mode-row">
<div
class="is-mode-btn active"
>
<span class="is-mode-icon">GPU</span>
<span class="is-mode-label">Whisper</span>
</div>
</div>
<div class="is-status">
<span
class="is-status-dot"
:class="{
ready: voice.whisperStatus.value === 'ready',
loading: voice.whisperStatus.value === 'loading',
offline: voice.whisperStatus.value === 'offline'
}"
></span>
<span class="is-status-text">
{{ voice.whisperStatus.value === 'ready' ? 'Whisper ready' : voice.whisperStatus.value === 'loading' ? 'Starting...' : 'Offline' }}
</span>
</div>
</div>
<!-- Microphone Selection -->
<div class="is-section">
<label class="is-label">Microphone</label>
<select
class="is-select"
:value="voice.selectedDeviceId.value"
@change="handleMicSelect"
>
<option
v-for="device in voice.audioDevices.value"
:key="device.deviceId"
:value="device.deviceId"
>
{{ device.label || `Microphone ${voice.audioDevices.value.indexOf(device) + 1}` }}
</option>
</select>
</div>
<!-- Debug: Last Audio Playback -->
<div class="is-section">
<label class="is-label">Debug</label>
<button
class="is-debug-btn"
:class="{ playing: voice.isPlayingAudio.value }"
:disabled="!voice.lastAudioUrl.value"
@click="voice.playLastAudio()"
>
<svg v-if="!voice.isPlayingAudio.value" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="4" width="4" height="16" rx="1"/>
<rect x="14" y="4" width="4" height="16" rx="1"/>
</svg>
<span>{{ voice.isPlayingAudio.value ? 'Stop' : 'Play last audio' }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.input-settings {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 12px;
margin-top: 8px;
animation: settings-in 0.2s ease-out;
}
.is-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.is-title {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.is-close {
width: 20px;
height: 20px;
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;
}
.is-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
}
.is-section {
margin-bottom: 10px;
}
.is-section:last-child {
margin-bottom: 0;
}
.is-label {
display: block;
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 6px;
}
.is-mode-row {
display: flex;
gap: 6px;
}
.is-mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
color: rgba(255, 255, 255, 0.4);
}
.is-mode-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.is-mode-btn.active {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.3);
color: rgba(139, 92, 246, 0.9);
}
.is-mode-btn.loading {
border-color: rgba(251, 191, 36, 0.3);
color: rgba(251, 191, 36, 0.8);
}
.is-mode-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.is-mode-icon {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
.is-mode-label {
font-size: 10px;
font-weight: 500;
}
.is-status {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
}
.is-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
.is-status-dot.ready {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
.is-status-dot.loading {
background: #fbbf24;
animation: status-pulse 1s ease-in-out infinite;
}
.is-status-dot.offline {
background: rgba(255, 255, 255, 0.2);
}
.is-status-text {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
}
.is-select {
width: 100%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
outline: none;
cursor: pointer;
font-family: system-ui, sans-serif;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.3)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 28px;
}
.is-select:focus {
border-color: rgba(255, 255, 255, 0.2);
}
.is-select option {
background: #1a1025;
color: rgba(255, 255, 255, 0.85);
}
.is-debug-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 7px 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-family: system-ui, sans-serif;
cursor: pointer;
transition: all 0.15s;
}
.is-debug-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
.is-debug-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.is-debug-btn.playing {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
color: rgba(59, 130, 246, 0.9);
}
@keyframes settings-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +0,0 @@
<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

@@ -1,182 +0,0 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount } from 'vue'
import type { VoiceCapture } from '../../composables/useVoiceCapture'
const props = defineProps<{
voice: VoiceCapture
}>()
const emit = defineEmits<{
done: [text: string]
}>()
function handleStop() {
props.voice.stopRecording()
// Brief delay for final Whisper result
const delay = props.voice.voiceMode.value === 'whisper' ? 800 : 200
setTimeout(() => {
emit('done', props.voice.transcript.value.trim())
}, delay)
}
onMounted(() => {
if (!props.voice.isRecording.value) {
props.voice.clearTranscript()
props.voice.startRecording()
}
})
onBeforeUnmount(() => {
if (props.voice.isRecording.value) {
props.voice.stopRecording()
}
})
</script>
<template>
<div class="transcript-card">
<div class="transcript-header">
<div class="transcript-header-left">
<span class="rec-dot"></span>
<span class="rec-label">Transcribiendo...</span>
<span class="mode-badge" :class="voice.voiceMode.value">
{{ voice.voiceMode.value === 'whisper' ? 'GPU' : 'Web' }}
</span>
</div>
<button class="stop-btn" @click="handleStop" title="Stop recording">
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
</div>
<!-- Error state -->
<div v-if="voice.error.value" class="transcript-error">
{{ voice.error.value }}
</div>
<!-- Transcript body -->
<div v-else class="transcript-body">
<span class="transcript-text">{{ voice.animatedTranscript.value }}</span>
<span v-if="voice.interimTranscript.value" class="interim-text">{{ voice.interimTranscript.value }}</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;
justify-content: space-between;
margin-bottom: 8px;
}
.transcript-header-left {
display: flex;
align-items: center;
gap: 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;
}
.mode-badge {
font-size: 9px;
font-weight: 700;
padding: 2px 5px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.mode-badge.whisper {
background: rgba(139, 92, 246, 0.2);
color: rgba(139, 92, 246, 0.9);
border: 1px solid rgba(139, 92, 246, 0.3);
}
.mode-badge.webspeech {
background: rgba(59, 130, 246, 0.2);
color: rgba(59, 130, 246, 0.9);
border: 1px solid rgba(59, 130, 246, 0.3);
}
.stop-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
cursor: pointer;
transition: all 0.15s;
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.25);
}
.transcript-body {
font-size: 13px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
min-height: 20px;
}
.transcript-text {
white-space: pre-wrap;
}
.interim-text {
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
.transcript-error {
font-size: 12px;
color: rgba(239, 68, 68, 0.8);
padding: 4px 0;
}
.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>

View File

@@ -73,8 +73,6 @@ function toggleSelectMode() {
if (!selectMode.value) selectedUuids.value = new Set() if (!selectMode.value) selectedUuids.value = new Set()
} }
defineExpose({ selectMode, toggleSelectMode })
function toggleSelect(uuid: string) { function toggleSelect(uuid: string) {
if (!selectMode.value) return if (!selectMode.value) return
const s = new Set(selectedUuids.value) const s = new Set(selectedUuids.value)
@@ -143,12 +141,21 @@ async function copySelected() {
// ── Collapse sections ── // ── Collapse sections ──
const collapsedSections = ref(new Set<string>()) const collapsedSections = ref(new Set<string>())
// Special user messages (interrupted, meta) are NOT section leaders —
// they belong to the previous normal user message's section.
function isSpecialUserMessage(msg: ConversationMessage): boolean {
if (msg.kind !== 'user') return false
if (msg.isMeta) return true
if (msg.content?.includes('[Request interrupted by user')) return true
return false
}
// For each message, find which user message "owns" it (section leader) // For each message, find which user message "owns" it (section leader)
const sectionMap = computed(() => { const sectionMap = computed(() => {
const map = new Map<string, string>() // messageUuid → ownerUserUuid const map = new Map<string, string>() // messageUuid → ownerUserUuid
let currentUserUuid: string | null = null let currentUserUuid: string | null = null
for (const msg of props.conversation.messages) { for (const msg of props.conversation.messages) {
if (msg.kind === 'user') { if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
currentUserUuid = msg.uuid currentUserUuid = msg.uuid
} else if (currentUserUuid) { } else if (currentUserUuid) {
map.set(msg.uuid, currentUserUuid) map.set(msg.uuid, currentUserUuid)
@@ -157,12 +164,12 @@ const sectionMap = computed(() => {
return map return map
}) })
// Count of non-user messages per section // Count of non-leader messages per section
const sectionCounts = computed(() => { const sectionCounts = computed(() => {
const counts = new Map<string, number>() const counts = new Map<string, number>()
let currentUserUuid: string | null = null let currentUserUuid: string | null = null
for (const msg of props.conversation.messages) { for (const msg of props.conversation.messages) {
if (msg.kind === 'user') { if (msg.kind === 'user' && !isSpecialUserMessage(msg)) {
currentUserUuid = msg.uuid currentUserUuid = msg.uuid
if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0) if (!counts.has(currentUserUuid)) counts.set(currentUserUuid, 0)
} else if (currentUserUuid) { } else if (currentUserUuid) {
@@ -180,11 +187,38 @@ function toggleCollapse(userUuid: string) {
} }
function isCollapsedChild(msg: { uuid: string; kind: string }): boolean { function isCollapsedChild(msg: { uuid: string; kind: string }): boolean {
if (msg.kind === 'user') return false // Special user messages collapse with their section like any other child
if (msg.kind === 'user' && !isSpecialUserMessage(msg as ConversationMessage)) return false
const owner = sectionMap.value.get(msg.uuid) const owner = sectionMap.value.get(msg.uuid)
return !!owner && collapsedSections.value.has(owner) return !!owner && collapsedSections.value.has(owner)
} }
// Collapse all user sections except the last one (only normal user messages)
const userUuids = computed(() =>
props.conversation.messages
.filter(m => m.kind === 'user' && !isSpecialUserMessage(m))
.map(m => m.uuid)
)
const allCollapsed = computed(() => {
const uuids = userUuids.value
if (uuids.length <= 1) return false
const toCollapse = uuids.slice(0, -1)
return toCollapse.length > 0 && toCollapse.every(u => collapsedSections.value.has(u))
})
function collapseAllExceptLast() {
const uuids = userUuids.value
if (uuids.length <= 1) return
if (allCollapsed.value) {
collapsedSections.value = new Set()
} else {
collapsedSections.value = new Set(uuids.slice(0, -1))
}
}
defineExpose({ selectMode, toggleSelectMode, allCollapsed, collapseAllExceptLast })
// Track messages that just resolved from optimistic → real // Track messages that just resolved from optimistic → real
// These skip the bounce animation and get a smooth transition instead // These skip the bounce animation and get a smooth transition instead
const resolvedUuids = ref(new Set<string>()) const resolvedUuids = ref(new Set<string>())

View File

@@ -1,421 +0,0 @@
/**
* useAgentTerminal
*
* Composable for managing a per-agent terminal session.
* Wraps useTerminalRenderer with agent-specific WebSocket connection,
* agent lifecycle (start/stop), and prompt sending.
*/
import { ref, computed, type Ref } from 'vue'
import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer'
import { agentTerminalUrl, terminalApiUrl } from '../config/endpoints'
export type AgentTerminalState = 'off' | 'connecting' | 'ready' | 'crashed' | 'agent-starting'
export interface AgentTerminal {
// State
terminalState: Ref<AgentTerminalState>
connected: Ref<boolean>
agentRunning: Ref<boolean>
sessionId: Ref<string | null>
// Container ref - bind this to the xterm DOM element in the component
containerRef: Ref<HTMLElement | null>
// Terminal renderer (for mounting xterm)
renderer: TerminalRenderer
// Connection
connect: () => void
disconnect: () => void
// Agent lifecycle
startAgent: (force?: boolean) => Promise<void>
stopAgent: () => Promise<void>
checkStatus: () => Promise<void>
// Prompt
sendPrompt: (text: string) => void
// Direct PTY input (char-by-char, no agent auto-start)
sendInput: (text: string) => void
// Raw PTY input (single write, no \r appended)
sendRaw: (data: string) => void
// Cleanup
dispose: () => void
}
export function useAgentTerminal(agentId: string): AgentTerminal {
const containerRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const agentRunning = ref(false)
const agentStarting = ref(false)
const crashed = ref(false)
const sessionId = ref<string | null>(null)
let socket: WebSocket | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
let pendingPrompt: string | null = null
const MAX_RECONNECT = 10
const RECONNECT_DELAY = 2000
const terminalState = computed<AgentTerminalState>(() => {
if (agentStarting.value) return 'agent-starting'
if (crashed.value) return 'crashed'
if (connected.value && agentRunning.value) return 'ready'
if (connecting.value) return 'connecting'
return 'off'
})
// Terminal renderer
const renderer = useTerminalRenderer({
container: containerRef,
onData: (data) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
},
onResize: (cols, rows) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
}
},
onKeyEvent: (e) => {
// Ctrl+V: Paste
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: text }))
}
}).catch(console.error)
return false
}
// Ctrl+C: Copy selection
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
const selection = renderer.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(console.error)
return false
}
}
return true
}
})
// ── WebSocket connection ──
function connect() {
if (connecting.value || connected.value) return
connecting.value = true
crashed.value = false
const wsUrl = agentTerminalUrl(agentId)
console.log(`[AgentTerminal:${agentId}] Connecting to ${wsUrl}`)
const timeout = window.setTimeout(() => {
if (connecting.value && !connected.value) {
connecting.value = false
socket?.close()
socket = null
scheduleReconnect()
}
}, 10000)
try {
socket = new WebSocket(wsUrl)
socket.addEventListener('open', () => clearTimeout(timeout), { once: true })
socket.onopen = () => {
connected.value = true
connecting.value = false
reconnectAttempts = 0
// Send initial resize
const term = renderer.terminal.value
if (term) {
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
handleMessage(msg)
} catch { /* ignore parse errors */ }
}
socket.onclose = () => {
connected.value = false
connecting.value = false
socket = null
if (reconnectAttempts < MAX_RECONNECT) {
scheduleReconnect()
}
}
socket.onerror = () => {
connecting.value = false
}
} catch {
connecting.value = false
scheduleReconnect()
}
}
function disconnect() {
cancelReconnect()
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
connected.value = false
connecting.value = false
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
reconnectAttempts++
const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5)
reconnectTimeout = window.setTimeout(() => {
if (!connected.value && !connecting.value) {
connect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
reconnectAttempts = 0
}
// ── Message handling ──
function handleMessage(msg: any) {
switch (msg.type) {
case 'connected':
sessionId.value = msg.sessionId
if (msg.hasHistory) {
// Session has existing output, request replay
setTimeout(() => requestReplay(), 50)
// Check if agent is actually running
checkStatus()
} else if (msg.isNew) {
// Brand new session, auto-start agent
autoStartAgent()
}
break
case 'replay':
renderer.handleReplay(msg.data || '')
break
case 'output':
renderer.write(msg.data)
break
case 'exit':
renderer.write(msg.data)
agentRunning.value = false
crashed.value = true
agentStarting.value = false
sessionId.value = null
break
case 'error':
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
break
case 'session-restart':
renderer.reset()
renderer.writeln('\x1b[33m[Session restarting...]\x1b[0m')
break
case 'claude-status':
if (matchesAgent(msg.agent)) {
if (msg.status === 'sessionStart') {
agentRunning.value = true
agentStarting.value = false
crashed.value = false
}
}
break
case 'buffer-cleared':
break
}
}
function matchesAgent(name: string): boolean {
if (!name) return agentId === 'main'
return name === agentId || name === 'main' && agentId === 'main'
}
function requestReplay(tailOnly = true) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 200 }))
}
}
// ── Agent lifecycle ──
async function autoStartAgent() {
agentStarting.value = true
try {
await startAgent()
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Auto-start failed:`, e)
agentStarting.value = false
}
}
async function startAgent(force = false) {
agentStarting.value = true
crashed.value = false
try {
const res = await fetch(terminalApiUrl('/start-agent'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId, force })
})
if (!res.ok) { agentStarting.value = false; return }
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) { agentStarting.value = false; return }
const data = await res.json()
if (data.success) {
agentRunning.value = true
// agentStarting stays true until sessionStart is received
// Flush pending prompt
if (pendingPrompt) {
setTimeout(() => {
flushPendingPrompt()
}, 500)
}
} else {
agentStarting.value = false
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Start failed:`, e)
agentStarting.value = false
}
}
async function stopAgent() {
try {
const res = await fetch(terminalApiUrl('/stop-agent'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentId })
})
if (res.ok) {
agentRunning.value = false
agentStarting.value = false
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Stop failed:`, e)
}
}
async function checkStatus() {
try {
const res = await fetch(terminalApiUrl('/agent-sessions'))
if (!res.ok) return
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) return
const data = await res.json()
const state = data[agentId]
if (state) {
agentRunning.value = state.isAgentRunning
if (!state.isAgentRunning && state.sessionExists) {
// Session exists but agent exited
crashed.value = true
}
}
} catch (e) {
console.error(`[AgentTerminal:${agentId}] Status check failed:`, e)
}
}
// ── Prompt sending ──
function typeTextToSocket(text: string) {
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
i++
setTimeout(typeChar, 15)
}
}
typeChar()
}
function sendPrompt(text: string) {
if (socket?.readyState === WebSocket.OPEN && (agentRunning.value || agentStarting.value)) {
typeTextToSocket(text)
} else if (!connected.value) {
// Queue prompt and auto-connect + start
pendingPrompt = text
connect()
} else if (connected.value && !agentRunning.value) {
// Connected but agent not running, start it and queue
pendingPrompt = text
startAgent()
}
}
function sendInput(text: string) {
if (socket?.readyState === WebSocket.OPEN) {
typeTextToSocket(text)
}
}
function sendRaw(data: string) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
}
function flushPendingPrompt() {
if (pendingPrompt && socket?.readyState === WebSocket.OPEN) {
typeTextToSocket(pendingPrompt)
pendingPrompt = null
}
}
// ── Cleanup ──
function dispose() {
disconnect()
renderer.dispose()
}
return {
terminalState,
connected,
agentRunning,
sessionId,
containerRef,
renderer,
connect,
disconnect,
startAgent,
stopAgent,
checkStatus,
sendPrompt,
sendInput,
sendRaw,
dispose
}
}

View File

@@ -1,397 +0,0 @@
import { ref, watch, type Ref } from 'vue'
import {
initWhisperSocket,
sendAudio,
onTranscription,
getWhisperStatus,
isConnected
} from '../services/whisperSocket'
export type WhisperStatus = 'offline' | 'loading' | 'ready'
export interface VoiceCapture {
// State
isRecording: Ref<boolean>
transcript: Ref<string>
interimTranscript: Ref<string>
animatedTranscript: Ref<string>
error: Ref<string>
voiceMode: Ref<'whisper'>
whisperStatus: Ref<WhisperStatus>
audioDevices: Ref<MediaDeviceInfo[]>
selectedDeviceId: Ref<string>
isAndroid: Ref<boolean>
lastAudioUrl: Ref<string>
isPlayingAudio: Ref<boolean>
// Actions
startRecording: () => void
stopRecording: () => void
loadAudioDevices: (skipPermission?: boolean) => Promise<void>
selectMicrophone: (deviceId: string) => void
playLastAudio: () => void
init: () => Promise<void>
cleanup: () => void
clearTranscript: () => void
}
const GPU_TIMEOUT_MS = 30_000 // 30s timeout waiting for GPU
export function useVoiceCapture(options?: {
onNotification?: (message: string, type: 'info' | 'success' | 'error', duration?: number) => void
}): VoiceCapture {
const notify = options?.onNotification || (() => {})
// ====== State ======
const isRecording = ref(false)
const transcript = ref('')
const interimTranscript = ref('')
const animatedTranscript = ref('')
const error = ref('')
const voiceMode = ref<'whisper'>('whisper') // Always whisper, no web speech
const audioDevices = ref<MediaDeviceInfo[]>([])
const selectedDeviceId = ref('')
const isAndroid = ref(false)
const lastAudioUrl = ref('')
const isPlayingAudio = ref(false)
// ====== Internal ======
const sharedWhisperStatus = getWhisperStatus()
const whisperStatus = ref<WhisperStatus>(sharedWhisperStatus.value)
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let chunkInterval: number | null = null
const CHUNK_INTERVAL_MS = 3000
let mediaStream: MediaStream | null = null
let supportedMimeType = 'audio/webm;codecs=opus'
let audioElement: HTMLAudioElement | null = null
let recordingStartTime = 0
let unsubTranscription: (() => void) | null = null
let gpuTimeout: number | null = null
// Typing animation
let typingTimeout: number | null = null
let lastAnimatedLength = 0
// Keep local status in sync with shared
watch(sharedWhisperStatus, (val) => {
whisperStatus.value = val
})
// ====== Mobile / Audio Format ======
function checkMobile() {
isAndroid.value = /Android/i.test(navigator.userAgent)
}
function detectAudioFormat(): string {
const formats = [
'audio/webm;codecs=opus', 'audio/webm',
'audio/mp4', 'audio/mp4;codecs=mp4a.40.2',
'audio/aac', 'audio/ogg;codecs=opus', 'audio/wav'
]
for (const f of formats) {
if (MediaRecorder.isTypeSupported(f)) {
console.log(`[VoiceCapture] Audio format: ${f}`)
return f
}
}
return ''
}
// ====== Whisper transcription handler ======
function handleTranscription(msg: any) {
if (!isRecording.value) return
if (msg.success && msg.text) {
const fullText = msg.text.trim()
transcript.value = fullText + ' '
interimTranscript.value = ''
if (!msg.partial) {
console.log(`[VoiceCapture] WHISPER (${msg.model}/${msg.device}):`, fullText)
}
} else if (msg.error) {
error.value = msg.error
console.error('[VoiceCapture] Whisper error:', msg.error)
}
}
// ====== Recording ======
function startRecording() {
error.value = ''
// Start capturing audio immediately, regardless of GPU status
startMediaRecorder()
// If GPU not ready yet, start timeout
if (!isConnected()) {
console.log('[VoiceCapture] Recording started, waiting for GPU...')
gpuTimeout = window.setTimeout(() => {
if (isRecording.value && !isConnected()) {
error.value = 'Whisper GPU timeout — server not available'
notify('Whisper GPU not available', 'error')
stopRecording()
}
}, GPU_TIMEOUT_MS)
}
}
async function startMediaRecorder() {
try {
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
...(selectedDeviceId.value ? { deviceId: { exact: selectedDeviceId.value } } : {})
}
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints })
const recorderOptions: MediaRecorderOptions = {}
if (supportedMimeType) {
recorderOptions.mimeType = supportedMimeType
}
mediaRecorder = new MediaRecorder(mediaStream, recorderOptions)
console.log(`[VoiceCapture] MediaRecorder using: ${mediaRecorder.mimeType}`)
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
}
}
mediaRecorder.start(100)
isRecording.value = true
recordingStartTime = Date.now()
// Reload devices with labels now that we have permission
loadAudioDevices(true)
// Send chunks periodically — only when GPU is connected
chunkInterval = window.setInterval(() => {
if (audioChunks.length > 0 && isConnected()) {
// GPU came online — clear timeout if still pending
if (gpuTimeout) {
clearTimeout(gpuTimeout)
gpuTimeout = null
}
sendAudioChunk(false)
}
}, CHUNK_INTERVAL_MS)
} catch (e: any) {
error.value = `Microphone error: ${e.message}`
console.error('[VoiceCapture] Microphone error:', e)
}
}
function sendAudioChunk(isFinal: boolean) {
if (audioChunks.length === 0) return
if (!isConnected()) {
console.log('[VoiceCapture] GPU not connected, holding audio')
return
}
const mimeType = mediaRecorder?.mimeType || supportedMimeType || 'audio/webm'
const audioBlob = new Blob(audioChunks, { type: mimeType })
if (audioBlob.size < 5000) {
if (isFinal) audioChunks = []
return
}
if (isFinal) {
audioChunks = []
saveAudioForPlayback(audioBlob)
}
const reader = new FileReader()
reader.onloadend = () => {
const base64 = (reader.result as string).split(',')[1]
sendAudio(base64, 'es', !isFinal)
}
reader.readAsDataURL(audioBlob)
}
function stopRecording() {
if (gpuTimeout) {
clearTimeout(gpuTimeout)
gpuTimeout = null
}
if (chunkInterval) {
clearInterval(chunkInterval)
chunkInterval = null
}
// Send final chunk (only if GPU is connected)
if (audioChunks.length > 0) {
sendAudioChunk(true)
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
mediaStream = null
}
isRecording.value = false
interimTranscript.value = ''
}
// ====== Audio Save & Playback ======
function currentMicName(): string {
if (!selectedDeviceId.value) return 'Default'
const device = audioDevices.value.find(d => d.deviceId === selectedDeviceId.value)
return device?.label || 'Microphone'
}
function saveAudioForPlayback(blob: Blob) {
if (lastAudioUrl.value) URL.revokeObjectURL(lastAudioUrl.value)
lastAudioUrl.value = URL.createObjectURL(blob)
saveRecordingToBackend(blob)
}
async function saveRecordingToBackend(blob: Blob) {
try {
const duration_ms = Date.now() - recordingStartTime
const reader = new FileReader()
reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1]
const response = await fetch('/api/recordings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
audio: base64,
transcription: transcript.value.trim(),
microphone: currentMicName(),
duration_ms
})
})
const data = await response.json()
if (data.success) {
console.log(`[VoiceCapture] Recording saved: ${data.filename}`)
}
}
reader.readAsDataURL(blob)
} catch (e) {
console.error('[VoiceCapture] Error saving recording:', e)
}
}
function playLastAudio() {
if (!lastAudioUrl.value) return
if (isPlayingAudio.value && audioElement) {
audioElement.pause()
audioElement.currentTime = 0
isPlayingAudio.value = false
return
}
audioElement = new Audio(lastAudioUrl.value)
audioElement.onplay = () => { isPlayingAudio.value = true }
audioElement.onended = () => { isPlayingAudio.value = false }
audioElement.onpause = () => { isPlayingAudio.value = false }
audioElement.play().catch(() => { isPlayingAudio.value = false })
}
// ====== Microphone ======
async function loadAudioDevices(skipPermissionRequest = false) {
try {
if (!skipPermissionRequest) {
const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true })
tempStream.getTracks().forEach(track => track.stop())
}
const devices = await navigator.mediaDevices.enumerateDevices()
audioDevices.value = devices.filter(d => d.kind === 'audioinput')
if (!selectedDeviceId.value && audioDevices.value.length > 0) {
selectedDeviceId.value = audioDevices.value[0]?.deviceId || ''
}
} catch (e) {
console.error('[VoiceCapture] Failed to enumerate devices:', e)
}
}
function selectMicrophone(deviceId: string) {
selectedDeviceId.value = deviceId
if (isRecording.value) {
stopRecording()
setTimeout(() => startRecording(), 100)
}
}
// ====== Typing Animation ======
function animateTyping(targetText: string) {
if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null }
if (targetText.length < animatedTranscript.value.length) {
animatedTranscript.value = targetText
lastAnimatedLength = targetText.length
return
}
const startIndex = lastAnimatedLength
function typeNext(index: number) {
if (index <= targetText.length) {
animatedTranscript.value = targetText.substring(0, index)
lastAnimatedLength = index
if (index < targetText.length) {
typingTimeout = window.setTimeout(() => typeNext(index + 1), 15 + Math.random() * 10)
}
}
}
typeNext(startIndex)
}
watch(transcript, (v) => animateTyping(v))
// ====== Transcript ======
function clearTranscript() {
transcript.value = ''
interimTranscript.value = ''
animatedTranscript.value = ''
lastAnimatedLength = 0
if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null }
}
// ====== Lifecycle ======
async function init() {
checkMobile()
supportedMimeType = detectAudioFormat()
await loadAudioDevices(true)
// Subscribe to shared whisper transcriptions
if (!unsubTranscription) {
unsubTranscription = onTranscription(handleTranscription)
}
// Initialize shared Whisper socket (singleton, safe to call multiple times)
initWhisperSocket()
console.log('[VoiceCapture] Initialized (Whisper-only, record-first)')
}
function cleanup() {
stopRecording()
if (unsubTranscription) { unsubTranscription(); unsubTranscription = null }
if (chunkInterval) clearInterval(chunkInterval)
if (typingTimeout) clearTimeout(typingTimeout)
if (gpuTimeout) clearTimeout(gpuTimeout)
if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null }
if (audioElement) { audioElement.pause(); audioElement = null }
if (lastAudioUrl.value) { URL.revokeObjectURL(lastAudioUrl.value); lastAudioUrl.value = '' }
isPlayingAudio.value = false
}
return {
isRecording, transcript, interimTranscript, animatedTranscript,
error, voiceMode, whisperStatus, audioDevices, selectedDeviceId,
isAndroid, lastAudioUrl, isPlayingAudio,
startRecording, stopRecording, loadAudioDevices, selectMicrophone,
playLastAudio, init, cleanup, clearTranscript
}
}

View File

@@ -1,384 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { endpoints } from '../config/endpoints'
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
const terminalContainer = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const error = ref<string | null>(null)
const sessionId = ref<string | null>(null)
const isResumedSession = ref(false)
let socket: WebSocket | null = null
const WS_URL = endpoints.terminal
// Terminal theme for TerminalPage (different from FloatingTerminal)
const TERMINAL_PAGE_THEME = {
background: '#0f0f14',
foreground: '#e4e4e7',
cursor: '#6366f1',
cursorAccent: '#0f0f14',
selectionBackground: 'rgba(99, 102, 241, 0.3)',
black: '#16161d',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#e4e4e7',
brightBlack: '#52525b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#ffffff'
}
// Use the composable
const renderer = useTerminalRenderer({
container: terminalContainer,
theme: TERMINAL_PAGE_THEME,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
onData: (data) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
},
onResize: (cols, rows) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
}
}
})
async function connect() {
if (connecting.value) return
connecting.value = true
error.value = null
try {
socket = new WebSocket(WS_URL)
socket.onopen = () => {
connected.value = true
connecting.value = false
renderer.focus()
const term = renderer.terminal.value
if (term) {
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
socket.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
sessionId.value = msg.sessionId
isResumedSession.value = !msg.isNew
if (msg.hasHistory) {
socket?.send(JSON.stringify({ type: 'request-replay', tailOnly: true, chunks: 200 }))
} else if (!msg.isNew) {
renderer.writeln('\x1b[36m[Reconnected to existing session]\x1b[0m')
}
} else if (msg.type === 'replay') {
renderer.handleReplay(msg.data || '')
} else if (msg.type === 'output') {
renderer.write(msg.data)
} else if (msg.type === 'exit') {
renderer.write(msg.data)
sessionId.value = null
} else if (msg.type === 'error') {
error.value = msg.message
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
}
}
socket.onclose = () => {
connected.value = false
connecting.value = false
if (sessionId.value) {
renderer.writeln('\x1b[33mDisconnected - session preserved on server\x1b[0m')
} else {
renderer.writeln('\x1b[33mConnection closed\x1b[0m')
}
}
socket.onerror = () => {
error.value = 'WebSocket connection failed. Make sure terminal server is running.'
connecting.value = false
}
} catch (e: any) {
error.value = e.message
connecting.value = false
}
}
function disconnect() {
if (socket) {
socket.close()
socket = null
}
connected.value = false
}
function clearTerminal() {
renderer.clear()
renderer.reset()
}
async function copyContent() {
const content = renderer.getBufferContent()
if (!content) return
try {
await navigator.clipboard.writeText(content)
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
if (btn) {
btn.classList.add('copied')
setTimeout(() => btn.classList.remove('copied'), 1500)
}
} catch (e) {
console.error('Failed to copy:', e)
}
}
function runClaudeCode() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
}
}
onMounted(async () => {
await nextTick()
renderer.init()
connect()
})
onBeforeUnmount(() => {
socket?.close()
renderer.dispose()
})
</script>
<template>
<div class="terminal-page">
<!-- Toolbar -->
<div class="terminal-toolbar">
<div class="toolbar-left">
<h2>Terminal</h2>
<span v-if="connected" class="status connected">
Connected
<span v-if="sessionId" class="session-badge">{{ sessionId }}</span>
</span>
<span v-else-if="connecting" class="status connecting">Connecting...</span>
<span v-else class="status disconnected">
Disconnected
<span v-if="sessionId" class="session-hint">(session active on server)</span>
</span>
</div>
<div class="toolbar-actions">
<button v-if="!connected" class="btn-primary" @click="connect" :disabled="connecting">
Connect
</button>
<button v-else class="btn-secondary" @click="disconnect">
Disconnect
</button>
<button class="btn-accent" @click="runClaudeCode" :disabled="!connected" title="Run Claude Code">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Claude
</button>
<button class="btn-icon btn-copy" @click="copyContent" title="Copy terminal content">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button class="btn-icon" @click="clearTerminal" title="Clear terminal">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</div>
<!-- Error message -->
<div v-if="error && !connected" class="error-banner">
{{ error }}
<p class="error-hint">Make sure the server is running with <code>bun start</code> in the server folder</p>
</div>
<!-- Terminal container -->
<div ref="terminalContainer" class="terminal-container"></div>
</div>
</template>
<style scoped>
.terminal-page {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
background: #0f0f14;
}
.terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.toolbar-left h2 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.status {
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-weight: 500;
}
.status.connected { background: var(--success-bg); color: var(--success); }
.status.connecting { background: var(--warning-bg); color: var(--warning); }
.status.disconnected { background: var(--error-bg); color: var(--error); }
.session-badge {
margin-left: 0.5rem;
padding: 0.125rem 0.375rem;
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
font-size: 0.7rem;
font-family: var(--font-mono);
}
.session-hint {
margin-left: 0.25rem;
font-size: 0.7rem;
opacity: 0.8;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
}
.btn-secondary:hover { background: var(--bg-tertiary); }
.btn-accent {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-accent:hover:not(:disabled) { opacity: 0.9; }
.btn-accent:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-icon {
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-icon.btn-copy.copied { color: var(--success); background: var(--success-bg); }
.error-banner {
padding: 1rem;
background: var(--error-bg);
color: var(--error);
font-size: 0.875rem;
border-bottom: 1px solid var(--error);
}
.error-hint {
margin: 0.5rem 0 0;
font-size: 0.8rem;
opacity: 0.8;
}
.error-hint code {
background: rgba(0, 0, 0, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: var(--font-mono);
}
.terminal-container {
flex: 1;
padding: 0.5rem;
overflow: hidden;
}
/* xterm overrides */
.terminal-container :deep(.xterm) { height: 100%; padding: 0.5rem; }
.terminal-container :deep(.xterm-viewport) { overflow-y: auto !important; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) { width: 10px; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) { background: #16161d; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: #2a2a3a; border-radius: 5px; }
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: #3a3a4a; }
</style>

View File

@@ -1,119 +0,0 @@
/**
* Terminal UI control handlers
* Controls the FloatingTerminal window (open, close, move, resize)
*/
import type { ToolConfig } from './index'
export interface TerminalControls {
open: (x?: number, y?: number) => void
close: () => void
toggle: () => void
move: (x: number, y: number) => void
resize: (width: number, height: number) => void
getState: () => { isOpen: boolean; position: { x: number; y: number }; size: { w: number; h: number } }
}
// Global reference to terminal controls (set by App.vue)
let terminalControls: TerminalControls | null = null
export function setTerminalControls(controls: TerminalControls) {
terminalControls = controls
;(window as any).__terminalControls = controls
}
export function getTerminalControls(): TerminalControls | null {
return terminalControls
}
export function createTerminalHandlers(): ToolConfig[] {
return [
{
name: 'terminal_open',
description: 'Abre la ventana flotante del terminal. Opcionalmente en una posicion especifica.',
category: 'terminal',
schema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Posicion X en pixels (opcional)' },
y: { type: 'number', description: 'Posicion Y en pixels (opcional)' }
}
},
handler: (args: { x?: number; y?: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.open(args.x, args.y)
const pos = args.x !== undefined && args.y !== undefined
? ` en posicion (${args.x}, ${args.y})`
: ''
return `Terminal abierta${pos}`
}
},
{
name: 'terminal_close',
description: 'Cierra la ventana flotante del terminal.',
category: 'terminal',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.close()
return 'Terminal cerrada'
}
},
{
name: 'terminal_toggle',
description: 'Alterna el estado de la ventana del terminal (abre si esta cerrada, cierra si esta abierta).',
category: 'terminal',
schema: {
type: 'object',
properties: {}
},
handler: () => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
const wasOpen = terminalControls.getState().isOpen
terminalControls.toggle()
return wasOpen ? 'Terminal cerrada' : 'Terminal abierta'
}
},
{
name: 'terminal_move',
description: 'Mueve la ventana del terminal a una posicion especifica en pixels.',
category: 'terminal',
schema: {
type: 'object',
properties: {
x: { type: 'number', description: 'Posicion X en pixels' },
y: { type: 'number', description: 'Posicion Y en pixels' }
},
required: ['x', 'y']
},
handler: (args: { x: number; y: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
terminalControls.move(args.x, args.y)
return `Terminal movida a (${args.x}, ${args.y})`
}
},
{
name: 'terminal_resize',
description: 'Cambia el tamano de la ventana del terminal.',
category: 'terminal',
schema: {
type: 'object',
properties: {
width: { type: 'number', description: 'Ancho en pixels (min 400)' },
height: { type: 'number', description: 'Alto en pixels (min 250)' }
},
required: ['width', 'height']
},
handler: (args: { width: number; height: number }) => {
if (!terminalControls) return 'Error: Terminal controls not initialized'
const w = Math.max(400, args.width)
const h = Math.max(250, args.height)
terminalControls.resize(w, h)
return `Terminal redimensionada a ${w}x${h}`
}
}
]
}

View File

@@ -1,67 +0,0 @@
export type ClaudeStatus =
| 'idle'
| 'processing'
| 'toolUse'
| 'toolDone'
| 'reading'
| 'writing'
| 'sessionStart'
| 'subagentStart'
| 'subagentStop'
| 'notification'
| 'permissionRequest'
| 'thinking'
export interface AgentStatusState {
isProcessing: boolean
isReading: boolean
isWriting: boolean
awaitingPermission: boolean
showToolFlash: boolean
showNotification: boolean
currentTool: string | null
}
export interface UiConfig {
label: string
shortLabel: string
color: string
gradient: string
terminalBg: string
terminalBorder: string
enabled: boolean
command?: string
}
export interface Agent {
id: string
name: string
directory: string
uiConfig: UiConfig | null
}
export interface ConversationEntry {
id: string
role: 'user' | 'agent'
content: string
timestamp: string
method: 'text' | 'voice'
}
export interface TranscriptSession {
id: string
startTime: string
messageCount: number
model: string
}
export interface TranscriptMessage {
uuid: string
role: 'user' | 'assistant'
content: string
timestamp: string
isMeta: boolean
tokens?: { input: number; output: number }
toolCalls?: string[]
hasThinking: boolean
}