fix: Per-agent terminal isolation, floating terminal z-index, and char-by-char input
- Add :key to PromptBar to force remount on agent switch, fixing shared terminal session bug - Raise AgentTerminal z-index above PromptBar backdrop so floating terminal is visible/clickable - Send prompt text char-by-char (15ms delay) matching FloatingVoice pattern for Claude Code compat - Guard xterm dispose against unloaded addons to prevent errors on agent switch - Widen PromptBar panel from 360px to 420px to fit all ChatInput buttons
This commit is contained in:
@@ -345,6 +345,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<PromptBar
|
||||
v-if="activeAgent"
|
||||
:key="activeAgent.id"
|
||||
ref="promptBarRef"
|
||||
:agent="activeAgent"
|
||||
:anchor-rect="activeAnchorRect"
|
||||
|
||||
464
frontend/src/components/NotificationLog.vue
Normal file
464
frontend/src/components/NotificationLog.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useClaudeHooksStore } from '../stores/claude-hooks'
|
||||
import { useCanvasStore } from '../stores/canvas'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
source: 'hook' | 'canvas' | 'response'
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
title: string
|
||||
detail: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'notification-log'
|
||||
const MAX_ENTRIES = 500
|
||||
|
||||
const hooksStore = useClaudeHooksStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const entries = ref<LogEntry[]>(loadFromStorage())
|
||||
const filter = ref<'all' | 'hook' | 'canvas' | 'response'>('all')
|
||||
|
||||
// Track what we've already logged to avoid duplicates
|
||||
const seenHookIds = new Set<string>()
|
||||
const seenCanvasIds = new Set<number>()
|
||||
|
||||
// Seed seen IDs from existing entries on load
|
||||
entries.value.forEach(e => {
|
||||
if (e.source === 'hook') seenHookIds.add(e.id)
|
||||
})
|
||||
|
||||
function loadFromStorage(): LogEntry[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.value.slice(-MAX_ENTRIES)))
|
||||
} catch { /* quota exceeded — silently ignore */ }
|
||||
}
|
||||
|
||||
function addEntry(entry: LogEntry) {
|
||||
entries.value.push(entry)
|
||||
if (entries.value.length > MAX_ENTRIES) {
|
||||
entries.value = entries.value.slice(-MAX_ENTRIES)
|
||||
}
|
||||
saveToStorage()
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
entries.value = []
|
||||
seenHookIds.clear()
|
||||
seenCanvasIds.clear()
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString('es', { day: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
// Group entries by date
|
||||
function getFilteredEntries() {
|
||||
if (filter.value === 'all') return entries.value
|
||||
return entries.value.filter(e => e.source === filter.value)
|
||||
}
|
||||
|
||||
// Watch hook notifications
|
||||
watch(() => hooksStore.notifications, (notifs) => {
|
||||
for (const n of notifs) {
|
||||
if (seenHookIds.has(n.id)) continue
|
||||
seenHookIds.add(n.id)
|
||||
addEntry({
|
||||
id: n.id,
|
||||
source: 'hook',
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
detail: n.detail,
|
||||
timestamp: n.timestamp
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch canvas notifications
|
||||
watch(() => canvasStore.notifications, (notifs) => {
|
||||
for (const n of notifs) {
|
||||
if (seenCanvasIds.has(n.id)) continue
|
||||
seenCanvasIds.add(n.id)
|
||||
addEntry({
|
||||
id: `canvas_${n.id}`,
|
||||
source: 'canvas',
|
||||
type: n.type as LogEntry['type'],
|
||||
title: 'App',
|
||||
detail: n.message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Expose addResponseEntry for external use (FloatingResponse)
|
||||
function addResponseEntry(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info') {
|
||||
addEntry({
|
||||
id: `resp_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`,
|
||||
source: 'response',
|
||||
type,
|
||||
title: 'Agent Response',
|
||||
detail: message.length > 300 ? message.slice(0, 300) + '...' : message,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ addResponseEntry })
|
||||
|
||||
const count = ref(0)
|
||||
watch(entries, (e) => { count.value = e.length }, { immediate: true })
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
hook: 'Hook',
|
||||
canvas: 'App',
|
||||
response: 'Response'
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
info: '#6366f1',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="notif-log-toggle"
|
||||
:class="{ open: isOpen }"
|
||||
@click="isOpen = !isOpen"
|
||||
title="Notification Log"
|
||||
>
|
||||
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span v-if="count > 0" class="notif-badge">{{ count > 99 ? '99+' : count }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Panel -->
|
||||
<Transition name="notif-slide">
|
||||
<div v-if="isOpen" class="notif-log-panel">
|
||||
<div class="notif-log-header">
|
||||
<span class="notif-log-title">Notifications ({{ getFilteredEntries().length }})</span>
|
||||
<div class="notif-log-actions">
|
||||
<select v-model="filter" class="notif-filter">
|
||||
<option value="all">All</option>
|
||||
<option value="hook">Hooks</option>
|
||||
<option value="canvas">App</option>
|
||||
<option value="response">Response</option>
|
||||
</select>
|
||||
<button @click="clearAll" class="notif-clear" title="Clear all">Clear</button>
|
||||
<button @click="isOpen = false" class="notif-close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notif-log-body">
|
||||
<template v-if="getFilteredEntries().length">
|
||||
<div
|
||||
v-for="entry in [...getFilteredEntries()].reverse()"
|
||||
:key="entry.id"
|
||||
class="notif-entry"
|
||||
>
|
||||
<div class="notif-entry-accent" :style="{ background: typeColors[entry.type] }"></div>
|
||||
<div class="notif-entry-content">
|
||||
<div class="notif-entry-top">
|
||||
<span class="notif-source" :class="entry.source">{{ sourceLabels[entry.source] || entry.source }}</span>
|
||||
<span class="notif-type-dot" :style="{ background: typeColors[entry.type] }"></span>
|
||||
<span class="notif-entry-title">{{ entry.title }}</span>
|
||||
<span class="notif-entry-time">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="notif-entry-date">{{ formatDate(entry.timestamp) }}</span>
|
||||
</div>
|
||||
<div v-if="entry.detail" class="notif-entry-detail">{{ entry.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="notif-empty">No notifications yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notif-log-toggle {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 80px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 40, 50, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10003;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.notif-log-toggle:hover {
|
||||
background: rgba(60, 60, 75, 0.95);
|
||||
color: #ddd;
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.notif-log-toggle.open {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
color: #a5b4fc;
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 1px 4px;
|
||||
border-radius: 10px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.notif-log-panel {
|
||||
position: fixed;
|
||||
top: 44px;
|
||||
right: 12px;
|
||||
width: 380px;
|
||||
max-height: calc(100vh - 100px);
|
||||
background: rgba(18, 18, 24, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
z-index: 10003;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.notif-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-log-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.notif-log-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notif-filter {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #aaa;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notif-filter option {
|
||||
background: #1a1a24;
|
||||
}
|
||||
|
||||
.notif-clear,
|
||||
.notif-close {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.notif-close {
|
||||
font-size: 16px;
|
||||
padding: 0 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notif-clear:hover { background: rgba(239, 68, 68, 0.25); color: #fca5a5; }
|
||||
.notif-close:hover { background: rgba(255, 255, 255, 0.15); color: #ddd; }
|
||||
|
||||
/* Body */
|
||||
.notif-log-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.notif-log-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.notif-log-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.notif-log-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Entry */
|
||||
.notif-entry {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notif-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.notif-entry-accent {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-entry-content {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-entry-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.notif-source {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-source.hook {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.notif-source.canvas {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.notif-source.response {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.notif-type-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-entry-title {
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notif-entry-time {
|
||||
color: #555;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.notif-entry-date {
|
||||
color: #444;
|
||||
font-size: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-entry-detail {
|
||||
color: #777;
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
text-align: center;
|
||||
color: #555;
|
||||
padding: 40px 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.notif-slide-enter-active,
|
||||
.notif-slide-leave-active {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.notif-slide-enter-from,
|
||||
.notif-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.97);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.notif-log-panel {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
436
frontend/src/components/agent/AgentTerminal.vue
Normal file
436
frontend/src/components/agent/AgentTerminal.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<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'
|
||||
|
||||
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
|
||||
|
||||
// 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) {
|
||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||
isDragging.value = true
|
||||
const rect = windowRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
if (!hasCustomPosition.value) {
|
||||
position.value = { x: rect.left, y: rect.top }
|
||||
}
|
||||
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
document.addEventListener('mousemove', onDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
}
|
||||
|
||||
function onDrag(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
const w = windowRef.value?.offsetWidth || 560
|
||||
const h = windowRef.value?.offsetHeight || 340
|
||||
position.value = {
|
||||
x: Math.max(-w * 0.75, Math.min(e.clientX - dragOffset.value.x, window.innerWidth - w * 0.25)),
|
||||
y: Math.max(-h * 0.75, Math.min(e.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)
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
setTimeout(() => {
|
||||
renderer.onBecameVisible()
|
||||
}, 150)
|
||||
})
|
||||
} else {
|
||||
renderer.blur()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', 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">
|
||||
<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
|
||||
v-if="terminalState === 'crashed' || terminalState === 'off'"
|
||||
class="wc-btn start"
|
||||
title="Start Agent"
|
||||
@click.stop="handleRestart"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="agentRunning"
|
||||
class="wc-btn restart"
|
||||
title="Restart Agent"
|
||||
@click.stop="handleRestart"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
</button>
|
||||
<button
|
||||
class="wc-btn"
|
||||
title="Clear Buffer"
|
||||
@click.stop="clearBuffer"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></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>
|
||||
</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;
|
||||
}
|
||||
.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.start { color: #0a0; }
|
||||
.wc-btn.start:hover { background: rgba(16, 185, 129, 0.3); }
|
||||
.wc-btn.restart:hover { background: rgba(245, 158, 11, 0.3); color: #a80; }
|
||||
|
||||
.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-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>
|
||||
@@ -6,7 +6,10 @@ const props = defineProps<{
|
||||
recording?: boolean
|
||||
historyActive?: boolean
|
||||
settingsActive?: boolean
|
||||
terminalActive?: boolean
|
||||
disabled?: boolean
|
||||
autofocus?: boolean
|
||||
statusDot?: 'green' | 'yellow' | 'red' | 'gray' | ''
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -14,6 +17,7 @@ const emit = defineEmits<{
|
||||
mic: []
|
||||
'toggle-history': []
|
||||
'toggle-settings': []
|
||||
'toggle-terminal': []
|
||||
}>()
|
||||
|
||||
const inputText = ref('')
|
||||
@@ -42,12 +46,14 @@ defineExpose({ focus })
|
||||
|
||||
<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
|
||||
@@ -95,6 +101,17 @@ defineExpose({ focus })
|
||||
<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>
|
||||
|
||||
@@ -162,11 +179,39 @@ defineExpose({ focus })
|
||||
}
|
||||
|
||||
.ci-history.active,
|
||||
.ci-settings.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); }
|
||||
|
||||
@@ -3,11 +3,13 @@ import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount }
|
||||
import type { Agent } from '../../types/agent'
|
||||
import { endpoints } from '../../config/endpoints'
|
||||
import { useVoiceCapture } from '../../composables/useVoiceCapture'
|
||||
import { useAgentTerminal } from '../../composables/useAgentTerminal'
|
||||
import { useCanvasStore } from '../../stores/canvas'
|
||||
import ChatInput from './ChatInput.vue'
|
||||
import TranscriptCard from './TranscriptCard.vue'
|
||||
import InputSettings from './InputSettings.vue'
|
||||
import ConversationHistory from './ConversationHistory.vue'
|
||||
import AgentTerminal from './AgentTerminal.vue'
|
||||
|
||||
interface ClaudeUsage {
|
||||
subscription: { type: string; tier: string; label: string; multiplier: number }
|
||||
@@ -23,6 +25,7 @@ interface ChatMessage {
|
||||
content: string
|
||||
status: 'sent' | 'thinking' | 'done'
|
||||
uuid?: string
|
||||
toolCalls?: string[]
|
||||
intervention?: {
|
||||
type: 'permission' | 'question' | 'plan'
|
||||
requestId?: string
|
||||
@@ -60,6 +63,32 @@ const voice = useVoiceCapture({
|
||||
onNotification: (msg, type, duration) => canvasStore.showNotification(msg, type, duration)
|
||||
})
|
||||
|
||||
// Agent terminal composable
|
||||
const agentTerminal = useAgentTerminal(props.agent.id)
|
||||
const showTerminal = ref(false)
|
||||
|
||||
const statusDot = computed<'green' | 'yellow' | 'red' | 'gray' | ''>(() => {
|
||||
switch (agentTerminal.terminalState.value) {
|
||||
case 'ready': return 'green'
|
||||
case 'connecting':
|
||||
case 'agent-starting': return 'yellow'
|
||||
case 'crashed': return 'red'
|
||||
case 'off': return 'gray'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
const label = props.agent.uiConfig?.label || props.agent.name
|
||||
switch (agentTerminal.terminalState.value) {
|
||||
case 'off': return `Agent offline - type to start ${label}`
|
||||
case 'crashed': return `Agent crashed - type to restart ${label}`
|
||||
case 'connecting':
|
||||
case 'agent-starting': return `Starting ${label}...`
|
||||
default: return `Mensaje a ${label}...`
|
||||
}
|
||||
})
|
||||
|
||||
const contentEl = ref<HTMLDivElement | null>(null)
|
||||
const chatInputEl = ref<InstanceType<typeof ChatInput> | null>(null)
|
||||
const isRecording = ref(false)
|
||||
@@ -96,7 +125,7 @@ const panelStyle = computed(() => {
|
||||
const bubbleCenterX = props.anchorRect.left + props.anchorRect.width / 2
|
||||
const bottomOffset = window.innerHeight - props.anchorRect.top + 12
|
||||
|
||||
const panelWidth = 360
|
||||
const panelWidth = 420
|
||||
let left = bubbleCenterX - panelWidth / 2
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - panelWidth - 12))
|
||||
|
||||
@@ -112,6 +141,95 @@ const hasContent = computed(() =>
|
||||
messages.length > 0 || showTranscript.value || showHistory.value || showSettings.value
|
||||
)
|
||||
|
||||
// ── Tool chip registry ──
|
||||
|
||||
interface ToolMeta { icon: string; color: string; label?: string }
|
||||
|
||||
const TOOL_CATEGORIES: Record<string, ToolMeta> = {
|
||||
// File ops — cyan
|
||||
Read: { icon: '◉', color: '#22d3ee' },
|
||||
Edit: { icon: '✎', color: '#22d3ee' },
|
||||
Write: { icon: '✦', color: '#22d3ee' },
|
||||
Glob: { icon: '⊞', color: '#22d3ee' },
|
||||
Grep: { icon: '⊘', color: '#22d3ee' },
|
||||
// Terminal — green
|
||||
Bash: { icon: '▸', color: '#34d399' },
|
||||
KillShell: { icon: '✕', color: '#34d399', label: 'Kill' },
|
||||
// Tasks — amber
|
||||
Task: { icon: '◈', color: '#fbbf24' },
|
||||
TaskCreate: { icon: '+', color: '#fbbf24', label: 'Task+' },
|
||||
TaskUpdate: { icon: '↻', color: '#fbbf24', label: 'Task↻' },
|
||||
TaskList: { icon: '≡', color: '#fbbf24', label: 'Tasks' },
|
||||
TaskOutput: { icon: '◎', color: '#fbbf24', label: 'TaskOut' },
|
||||
TodoWrite: { icon: '☑', color: '#fbbf24', label: 'Todo' },
|
||||
// Web — purple
|
||||
WebSearch: { icon: '⌕', color: '#a78bfa' },
|
||||
WebFetch: { icon: '↓', color: '#a78bfa' },
|
||||
// Interaction — rose
|
||||
AskUserQuestion: { icon: '?', color: '#fb7185', label: 'Ask' },
|
||||
EnterPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan↵' },
|
||||
ExitPlanMode: { icon: '▤', color: '#fb7185', label: 'Plan✓' },
|
||||
Skill: { icon: '⚡', color: '#fb7185' },
|
||||
// Resources — slate
|
||||
ListMcpResourcesTool: { icon: '☰', color: '#94a3b8', label: 'Resources' },
|
||||
}
|
||||
|
||||
function getToolMeta(name: string): ToolMeta {
|
||||
if (TOOL_CATEGORIES[name]) return TOOL_CATEGORIES[name]
|
||||
if (name.startsWith('mcp__')) {
|
||||
const short = name.split('-').pop() || name
|
||||
return { icon: '◆', color: '#818cf8', label: short }
|
||||
}
|
||||
return { icon: '•', color: '#94a3b8', label: name }
|
||||
}
|
||||
|
||||
// ── Display items with tool grouping ──
|
||||
|
||||
interface DisplayItem {
|
||||
type: 'message' | 'tool-group'
|
||||
msg?: ChatMessage
|
||||
tools?: { name: string; count: number; meta: ToolMeta }[]
|
||||
ids?: number[]
|
||||
}
|
||||
|
||||
function buildToolGroup(tools: string[], ids: number[]): DisplayItem {
|
||||
const counts = new Map<string, number>()
|
||||
for (const t of tools) counts.set(t, (counts.get(t) || 0) + 1)
|
||||
return {
|
||||
type: 'tool-group',
|
||||
tools: [...counts.entries()].map(([name, count]) => ({
|
||||
name, count, meta: getToolMeta(name)
|
||||
})),
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
const displayMessages = computed<DisplayItem[]>(() => {
|
||||
const items: DisplayItem[] = []
|
||||
let pendingTools: string[] = []
|
||||
let pendingIds: number[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
const isToolOnly = msg.toolCalls?.length && msg.content.startsWith('[Tool calls:')
|
||||
|
||||
if (isToolOnly) {
|
||||
pendingTools.push(...msg.toolCalls!)
|
||||
pendingIds.push(msg.id)
|
||||
} else {
|
||||
if (pendingTools.length) {
|
||||
items.push(buildToolGroup(pendingTools, pendingIds))
|
||||
pendingTools = []
|
||||
pendingIds = []
|
||||
}
|
||||
items.push({ type: 'message', msg })
|
||||
}
|
||||
}
|
||||
if (pendingTools.length) {
|
||||
items.push(buildToolGroup(pendingTools, pendingIds))
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// ── Formatting helpers ──
|
||||
|
||||
function shortModel(model: string): string {
|
||||
@@ -241,7 +359,8 @@ function appendTranscriptMessages(newMsgs: any[]) {
|
||||
role: msg.role === 'user' ? 'user' : 'agent',
|
||||
content: msg.content || '',
|
||||
status: 'done',
|
||||
uuid: msg.uuid
|
||||
uuid: msg.uuid,
|
||||
toolCalls: msg.toolCalls || undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,7 +502,8 @@ async function loadHistory(sessionId?: string) {
|
||||
role: msg.role === 'user' ? 'user' : 'agent',
|
||||
content: msg.content || '',
|
||||
status: 'done',
|
||||
uuid: msg.uuid
|
||||
uuid: msg.uuid,
|
||||
toolCalls: msg.toolCalls || undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -437,6 +557,8 @@ function handleSessionChange(sessionId: string) {
|
||||
function handleSubmit(text: string) {
|
||||
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
||||
emit('submit', text)
|
||||
// Send text as input to the agent's terminal PTY
|
||||
agentTerminal.sendPrompt(text)
|
||||
// Real response will arrive via transcript-update WebSocket
|
||||
}
|
||||
|
||||
@@ -455,6 +577,7 @@ function handleTranscriptDone(text: string) {
|
||||
|
||||
messages.push({ id: ++idCounter, role: 'user', content: text, status: 'sent' })
|
||||
emit('submit', text)
|
||||
agentTerminal.sendPrompt(text)
|
||||
// Real response will arrive via transcript-update WebSocket
|
||||
}
|
||||
|
||||
@@ -467,6 +590,10 @@ function toggleHistory() {
|
||||
if (showHistory.value) scrollToBottom()
|
||||
}
|
||||
|
||||
function toggleTerminal() {
|
||||
showTerminal.value = !showTerminal.value
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
@@ -477,6 +604,7 @@ watch(() => props.visible, async (v) => {
|
||||
showTranscript.value = false
|
||||
showHistory.value = false
|
||||
showSettings.value = false
|
||||
showTerminal.value = false
|
||||
messages.length = 0
|
||||
idCounter = 0
|
||||
sessionStats.value = null
|
||||
@@ -493,6 +621,10 @@ watch(() => props.visible, async (v) => {
|
||||
// Connect WebSocket for real-time updates
|
||||
connectWs()
|
||||
|
||||
// Connect agent terminal (lazy)
|
||||
agentTerminal.connect()
|
||||
agentTerminal.checkStatus()
|
||||
|
||||
await voice.init()
|
||||
await nextTick()
|
||||
if (props.startRecording) {
|
||||
@@ -502,6 +634,8 @@ watch(() => props.visible, async (v) => {
|
||||
}
|
||||
} else {
|
||||
disconnectWs()
|
||||
agentTerminal.disconnect()
|
||||
showTerminal.value = false
|
||||
if (voice.isRecording.value) {
|
||||
voice.stopRecording()
|
||||
}
|
||||
@@ -518,6 +652,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
disconnectWs()
|
||||
agentTerminal.dispose()
|
||||
voice.cleanup()
|
||||
})
|
||||
</script>
|
||||
@@ -592,86 +727,109 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Conversation content area -->
|
||||
<div ref="contentEl" class="pb-content" :class="{ empty: !hasContent }">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="chat-msg"
|
||||
:class="msg.role"
|
||||
>
|
||||
<template v-if="msg.role === 'user'">
|
||||
<div class="msg-bubble user-bubble">{{ msg.content }}</div>
|
||||
</template>
|
||||
<template v-for="(item, idx) in displayMessages" :key="item.msg?.id || item.ids?.join('-') || idx">
|
||||
<!-- Tool group: chips compactos -->
|
||||
<div v-if="item.type === 'tool-group'" class="tool-chips-row">
|
||||
<span
|
||||
v-for="tool in item.tools"
|
||||
:key="tool.name"
|
||||
class="tool-chip"
|
||||
:style="{ '--chip-color': tool.meta.color }"
|
||||
>
|
||||
<span class="chip-icon">{{ tool.meta.icon }}</span>
|
||||
<span class="chip-label">{{ tool.meta.label || tool.name }}</span>
|
||||
<span v-if="tool.count > 1" class="chip-count">{{ tool.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Intervention card -->
|
||||
<template v-else-if="msg.intervention">
|
||||
<div class="intervention-card" :class="`intervention--${msg.intervention.type}`">
|
||||
<!-- Permission card -->
|
||||
<template v-if="msg.intervention.type === 'permission'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<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>
|
||||
<span class="intv-title">Permission: {{ msg.intervention.toolName }}</span>
|
||||
</div>
|
||||
<div class="intv-detail">{{ msg.content }}</div>
|
||||
<div v-if="!msg.intervention.resolved" class="intv-actions">
|
||||
<button class="intv-btn intv-btn--allow" @click="respondPermission(msg.id, msg.intervention.requestId!, 'allow')">Allow</button>
|
||||
<button class="intv-btn intv-btn--deny" @click="respondPermission(msg.id, msg.intervention.requestId!, 'deny')">Deny</button>
|
||||
</div>
|
||||
<div v-else class="intv-resolved" :class="msg.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
||||
{{ msg.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
||||
</div>
|
||||
</template>
|
||||
<!-- Regular message -->
|
||||
<div v-else class="chat-msg" :class="item.msg!.role">
|
||||
<template v-if="item.msg!.role === 'user'">
|
||||
<div class="msg-bubble user-bubble">{{ item.msg!.content }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Question card (info only) -->
|
||||
<template v-else-if="msg.intervention.type === 'question'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span class="intv-title">Question</span>
|
||||
</div>
|
||||
<div class="intv-detail">{{ msg.content }}</div>
|
||||
<div v-if="msg.intervention.options?.length" class="intv-options">
|
||||
<div v-for="(opt, i) in msg.intervention.options" :key="i" class="intv-option">
|
||||
<span class="opt-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
|
||||
<!-- Intervention card -->
|
||||
<template v-else-if="item.msg!.intervention">
|
||||
<div class="intervention-card" :class="`intervention--${item.msg!.intervention.type}`">
|
||||
<!-- Permission card -->
|
||||
<template v-if="item.msg!.intervention.type === 'permission'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<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>
|
||||
<span class="intv-title">Permission: {{ item.msg!.intervention.toolName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="intv-hint">Respond in terminal</div>
|
||||
</template>
|
||||
<div class="intv-detail">{{ item.msg!.content }}</div>
|
||||
<div v-if="!item.msg!.intervention.resolved" class="intv-actions">
|
||||
<button class="intv-btn intv-btn--allow" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'allow')">Allow</button>
|
||||
<button class="intv-btn intv-btn--deny" @click="respondPermission(item.msg!.id, item.msg!.intervention.requestId!, 'deny')">Deny</button>
|
||||
</div>
|
||||
<div v-else class="intv-resolved" :class="item.msg!.intervention.decision === 'allow' ? 'resolved--allow' : 'resolved--deny'">
|
||||
{{ item.msg!.intervention.decision === 'allow' ? 'Allowed' : 'Denied' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Plan card (info only) -->
|
||||
<template v-else-if="msg.intervention.type === 'plan'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span class="intv-title">{{ msg.content }}</span>
|
||||
</div>
|
||||
<div class="intv-hint">Review in terminal</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Question card (info only) -->
|
||||
<template v-else-if="item.msg!.intervention.type === 'question'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span class="intv-title">Question</span>
|
||||
</div>
|
||||
<div class="intv-detail">{{ item.msg!.content }}</div>
|
||||
<div v-if="item.msg!.intervention.options?.length" class="intv-options">
|
||||
<div v-for="(opt, i) in item.msg!.intervention.options" :key="i" class="intv-option">
|
||||
<span class="opt-label">{{ opt.label }}</span>
|
||||
<span v-if="opt.description" class="opt-desc">{{ opt.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="intv-hint">Respond in terminal</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="msg-bubble agent-bubble">
|
||||
<div v-if="msg.status === 'thinking'" class="thinking-inline">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<!-- Plan card (info only) -->
|
||||
<template v-else-if="item.msg!.intervention.type === 'plan'">
|
||||
<div class="intv-header">
|
||||
<svg class="intv-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span class="intv-title">{{ item.msg!.content }}</span>
|
||||
</div>
|
||||
<div class="intv-hint">Review in terminal</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="agent-text fade-in">{{ msg.content }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="msg-bubble agent-bubble">
|
||||
<div v-if="item.msg!.status === 'thinking'" class="thinking-inline">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="agent-text fade-in">{{ item.msg!.content }}</div>
|
||||
<!-- Inline tool chips for mixed messages (text + tools) -->
|
||||
<div v-if="item.msg!.toolCalls?.length && !item.msg!.content.startsWith('[Tool calls:')" class="inline-tools">
|
||||
<span
|
||||
v-for="t in item.msg!.toolCalls"
|
||||
:key="t"
|
||||
class="tool-chip-mini"
|
||||
:style="{ '--chip-color': getToolMeta(t).color }"
|
||||
>{{ getToolMeta(t).icon }} {{ getToolMeta(t).label || t }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TranscriptCard v-if="showTranscript" :voice="voice" @done="handleTranscriptDone" />
|
||||
<InputSettings
|
||||
@@ -688,17 +846,27 @@ onBeforeUnmount(() => {
|
||||
<!-- Input -->
|
||||
<ChatInput
|
||||
ref="chatInputEl"
|
||||
:placeholder="`Mensaje a ${agent.uiConfig?.label || agent.name}...`"
|
||||
:placeholder="inputPlaceholder"
|
||||
:recording="isRecording"
|
||||
:history-active="showHistory"
|
||||
:settings-active="showSettings"
|
||||
:terminal-active="showTerminal"
|
||||
:status-dot="statusDot"
|
||||
:autofocus="visible"
|
||||
@submit="handleSubmit"
|
||||
@mic="handleMic"
|
||||
@toggle-history="toggleHistory"
|
||||
@toggle-settings="toggleSettings"
|
||||
@toggle-terminal="toggleTerminal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Agent terminal floating window (shares composable instance) -->
|
||||
<AgentTerminal
|
||||
v-model="showTerminal"
|
||||
:agent="agent"
|
||||
:agent-terminal="agentTerminal"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -1137,6 +1305,74 @@ onBeforeUnmount(() => {
|
||||
50% { border-color: rgba(239, 68, 68, 0.4); }
|
||||
}
|
||||
|
||||
/* Tool chips row — consecutive tool calls share horizontal space */
|
||||
.tool-chips-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
animation: msg-in 0.2s ease-out;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
|
||||
color: var(--chip-color);
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tool-chip:hover {
|
||||
background: color-mix(in srgb, var(--chip-color) 22%, transparent);
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Reduce gap between consecutive tool rows */
|
||||
.tool-chips-row + .tool-chips-row {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
/* Inline tool chips for mixed messages (text + tool calls) */
|
||||
.inline-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tool-chip-mini {
|
||||
font-size: 9px;
|
||||
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
color: var(--chip-color);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.prompt-bar-panel {
|
||||
|
||||
Reference in New Issue
Block a user