Files
agent-ui/frontend/src/App.vue
josedario87 c46b1283d1 feat: voice assistant integration, PiP window fixes, widget improvements and pixel art scrollbar
- Android voice assistant: RecognitionService, VoiceInteractionSession with startAssistantActivity, es-HN speech recognition
- Voice transcript sent to first alive terminal via WebSocket, opens FloatingTranscriptDebug on correct session
- PiP window: fix close button using getCurrentWebviewWindow(), add mini/restore toggle, remove alwaysOnTop
- Add webview-close and window-destroy permissions to capabilities
- Pixel art ocean scrollbar on /transcript-debug respecting scroll nav mode settings
- Widget improvements: terminal list service, input widget provider, updated layouts
2026-02-23 22:35:58 -06:00

1305 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import Toolbar from './components/Toolbar.vue'
import TorchButton from './components/TorchButton.vue'
import FloatingResponse from './components/FloatingResponse.vue'
import { initWhisperSocket } from './services/whisperSocket'
import FloatingVoice from './components/FloatingVoice.vue'
import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue'
import ServerConfigDialog from './components/ServerConfigDialog.vue'
import WindowControls from './components/WindowControls.vue'
import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch'
import { initSessionStateWS, destroySessionStateWS } from './services/session-state-ws'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state'
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
import { useServerConfig } from './stores/server-config'
const route = useRoute()
const router = useRouter()
// Tauri server config
const serverConfig = isTauri ? useServerConfig() : null
const showServerConfig = ref(false)
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
const isPipWindow = computed(() => route.query.pip === '1')
const showVoice = ref(false)
const showTranscriptDebug = ref(false)
const showDebugConsole = ref(false)
const toolbarVisible = ref(true)
const forceWco = ref(isTauri && !isMobileTauri())
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
// Intercept console.log for debug panel
const originalConsoleLog = console.log
const originalConsoleWarn = console.warn
const originalConsoleError = console.error
function addDebugLog(type: string, args: any[]) {
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')
const time = new Date().toLocaleTimeString()
debugLogs.value.push({ type, message, time })
// Keep only last 100 logs
if (debugLogs.value.length > 100) {
debugLogs.value.shift()
}
}
console.log = (...args) => {
originalConsoleLog.apply(console, args)
addDebugLog('log', args)
}
console.warn = (...args) => {
originalConsoleWarn.apply(console, args)
addDebugLog('warn', args)
}
console.error = (...args) => {
originalConsoleError.apply(console, args)
addDebugLog('error', args)
}
function copyDebugLogs() {
const text = debugLogs.value.map(l => `[${l.time}] [${l.type}] ${l.message}`).join('\n')
navigator.clipboard.writeText(text)
canvasStore.showNotification('Logs copied!', 'success')
}
function clearDebugLogs() {
debugLogs.value = []
}
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
const transcriptDebugRef = ref<InstanceType<typeof FloatingTranscriptDebug> | null>(null)
const mousePos = ref({ x: 0, y: 0 })
const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
const sessionState = useSessionState()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
// Voice FAB push-to-talk state
const voicePTTActive = ref(false)
let voiceTouchStarted = false
let voicePTTTimeout: number | null = null
const keyboardVisible = ref(false) // Virtual keyboard visible
// Whether any terminal exists (T1+)
const hasTerminals = computed(() => sessionState.terminalRegistry.length > 0)
// Whether terminal 1 is the currently active terminal
const isT1Active = computed(() => {
const reg = sessionState.terminalRegistry
if (!reg.length) return false
return reg[0].transcriptSessionId === transcriptDebugRef.value?.activeTerminalSessionId
})
// Extra terminals (T2-T5) from Pinia store — fully reactive, no template ref dependency
const extraTerminals = computed(() => {
const reg = sessionState.terminalRegistry
if (reg.length <= 1) return []
return reg.slice(1).map(entry => ({
sessionId: entry.transcriptSessionId,
ephemeralSessionId: entry.ephemeralSessionId,
agent: entry.agent,
label: entry.label,
command: entry.command,
active: entry.transcriptSessionId === transcriptDebugRef.value?.activeTerminalSessionId,
alive: entry.alive,
clients: entry.clients
}))
})
function hardRefresh() {
location.reload()
}
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function handleFabTerminalSelect(sessionId: string) {
const isActive = transcriptDebugRef.value?.activeTerminalSessionId === sessionId
if (showTranscriptDebug.value && isActive) {
// Already showing this terminal — close
showTranscriptDebug.value = false
return
}
if (transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(sessionId)
}
showTranscriptDebug.value = true
}
function handleFabCreateSession() {
showTranscriptDebug.value = true
nextTick(() => {
transcriptDebugRef.value?.handleCreateSession()
})
}
function handleMainFabClick() {
const terminals = transcriptDebugRef.value?.openTerminals
if (!terminals?.length) return
const firstSessionId = terminals[0].sessionId
const isActive = transcriptDebugRef.value?.activeTerminalSessionId === firstSessionId
if (showTranscriptDebug.value && isActive) {
// Already showing terminal 1 — close
showTranscriptDebug.value = false
return
}
// Switch to terminal 1 and open
transcriptDebugRef.value?.switchToTerminal(firstSessionId)
showTranscriptDebug.value = true
}
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
if (transcriptDebugRef.value) {
transcriptDebugRef.value.openAtCursor(mousePos.value.x, mousePos.value.y)
} else {
showTranscriptDebug.value = !showTranscriptDebug.value
}
}
}
// Voice FAB push-to-talk handlers
function handleVoiceFabClick() {
// If touch just ended, ignore click
if (voiceTouchStarted) {
voiceTouchStarted = false
return
}
// Normal click: toggle panel
showVoice.value = !showVoice.value
}
function handleVoiceFabTouchStart(e: TouchEvent) {
e.preventDefault()
voiceTouchStarted = true
voicePTTActive.value = true
// Open panel and start recording
showVoice.value = true
// Wait a moment for panel to open, then start recording
setTimeout(() => {
voiceRef.value?.startRecording()
}, 100)
}
function handleVoiceFabTouchEnd(e: TouchEvent) {
e.preventDefault()
if (!voicePTTActive.value) return
// Add buffer before stopping
voicePTTTimeout = window.setTimeout(() => {
voiceRef.value?.stopRecordingAndSend()
voicePTTActive.value = false
}, 1000)
setTimeout(() => { voiceTouchStarted = false }, 100)
}
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'project-canvas' | 'database' | 'source' | 'tools' | 'agents'
function syncThemeColor() {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
if (bg) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg)
}
}
onMounted(async () => {
// Bridge for Android widget navigation (called from MainActivity via evaluateJavascript)
;(window as any).__WIDGET_NAVIGATE__ = (route: string) => {
router.push(route)
return true
}
// Bridge for Android voice assistant — navigates to transcript-debug page on the target terminal
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
const entry = sessionState.terminalRegistry.find(
t => t.ephemeralSessionId === ephemeralSessionId
)
const idx = entry
? sessionState.terminalRegistry.indexOf(entry) + 1
: 1
router.push(`/transcript-debug/${idx}`)
return true
}
// Sync Windows titlebar color with CSS variable
syncThemeColor()
// Connect global hooks approval WS
connectApproval()
fetchApprovalPending()
// Connect centralized session state WS
initSessionStateWS()
// Initialize Whisper WebSocket connection early
initWhisperSocket()
// Fire torch connection early (don't await yet)
const torchReady = initTorch()
// Initialize WebMCP connection
await initWebMCP()
// Initialize tool registry with router
initToolRegistry(router)
// Initialize tools for current page (handles refresh)
const currentPage = (route.name as string) || 'canvas'
initToolsOnRefresh(currentPage as PageName)
// Setup response controls for MCP tools
setResponseControls({
addMessage: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => {
if (responseRef.value) {
return responseRef.value.addMessage(message, type)
}
return ''
},
removeMessage: (id: string) => {
responseRef.value?.removeMessage(id)
},
clearAll: () => {
responseRef.value?.clearAll()
},
getMessages: () => {
return responseRef.value?.getMessages() || []
}
})
// Track mouse for Ctrl+E cursor-based opening
document.addEventListener('mousemove', trackMouse)
// Global keyboard shortcut: Ctrl+E toggles Transcript Debug
document.addEventListener('keydown', handleGlobalKeydown)
// Detect virtual keyboard on mobile
if (window.visualViewport) {
const initialHeight = window.visualViewport.height
window.visualViewport.addEventListener('resize', () => {
const currentHeight = window.visualViewport!.height
// If viewport shrinks significantly, keyboard is likely open
keyboardVisible.value = currentHeight < initialHeight * 0.75
})
}
// Ensure torch connection is established
await torchReady
})
async function sendTestNotification() {
const title = 'Agent UI'
const body = 'Test notification from Agent UI — all platforms!'
if (isTauri) {
try {
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
let granted = await isPermissionGranted()
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
}
if (granted) {
sendNotification({ title, body })
}
} catch (e) {
console.warn('[Notification] Tauri plugin failed:', e)
}
} else if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(title, { body })
} else if (Notification.permission !== 'denied') {
const perm = await Notification.requestPermission()
if (perm === 'granted') new Notification(title, { body })
}
}
}
onUnmounted(() => {
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleGlobalKeydown)
destroyTorch()
disconnectApproval()
destroySessionStateWS()
})
// Watch for route changes and update tools
watch(() => route.name, (newPage) => {
if (newPage) {
activatePageTools(newPage as PageName)
}
})
// Watch for Tauri server config changes — re-init services when server is configured
if (serverConfig) {
watch(() => serverConfig!.isConfigured, async (configured) => {
if (configured) {
showServerConfig.value = false
// Re-initialize all services with the new server URL
initSessionStateWS()
initWhisperSocket()
await initWebMCP()
await initTorch()
}
})
}
</script>
<template>
<div class="app-container" :class="{ wco: forceWco, 'pip-window': isPipWindow }">
<header v-if="!isPipWindow" class="app-header" :class="{ 'wco-header': forceWco }">
<div class="header-left">
<button
class="toolbar-toggle"
:class="{ collapsed: !toolbarVisible }"
@click="toolbarVisible = !toolbarVisible"
:title="toolbarVisible ? 'Ocultar toolbar' : 'Mostrar toolbar'"
>
<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">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</button>
<template v-if="projectCanvasStore.activeCanvas && route.name === 'project-canvas'">
<span class="header-sep">/</span>
<span class="header-canvas-name">{{ projectCanvasStore.activeCanvas.name }}</span>
<span v-if="projectCanvasStore.activeCanvas.is_system" class="header-canvas-badge">Sistema</span>
</template>
<template v-if="canvasStore.isAnonymousCanvas && route.name === 'canvas'">
<span class="header-sep">/</span>
<span class="header-canvas-name">anonimo</span>
</template>
<button class="debug-btn" :class="{ active: showDebugConsole }" @click="showDebugConsole = !showDebugConsole" title="Debug Console">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span>
</button>
<PwaInstallBanner v-if="!isTauri" />
<button v-if="isTauri" class="server-config-btn" @click="showServerConfig = true" title="Server settings">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</button>
</div>
<div class="header-right">
<button
v-if="totalPending > 0 || modalVisible"
class="approval-badge-btn"
:class="{ active: modalVisible, pulse: totalPending > 0 }"
@click="modalVisible = !modalVisible"
title="Hooks Approval"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
</button>
<button class="refresh-btn" @click="sendTestNotification" title="Test notification">
<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>
</button>
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
<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="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
</button>
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
<TorchButton />
<WindowControls />
</div>
</header>
<main class="app-main">
<Toolbar v-if="!isPipWindow" :collapsed="!toolbarVisible" />
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<!-- Transcript Debug FAB Button (pixel art ocean) hidden in PiP -->
<div v-if="!isPipWindow" class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
<TerminalFabStack
:terminals="extraTerminals"
:active-session-id="transcriptDebugRef?.activeTerminalSessionId ?? null"
@select="handleFabTerminalSelect"
@create-session="handleFabCreateSession"
/>
<div v-if="hasTerminals" class="fab-button-area">
<span class="fab-bubble b1"></span>
<span class="fab-bubble b2"></span>
<span class="fab-bubble b3"></span>
<button
class="transcript-fab"
:class="{ active: showTranscriptDebug, 't1-active': isT1Active }"
@click="handleMainFabClick"
@contextmenu.prevent
title="Transcript Debug"
>
<!-- Pixel art chat bubble icon -->
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" shape-rendering="crispEdges">
<rect x="4" y="2" width="12" height="2" fill="currentColor"/>
<rect x="2" y="4" width="2" height="8" fill="currentColor"/>
<rect x="16" y="4" width="2" height="8" fill="currentColor"/>
<rect x="4" y="12" width="12" height="2" fill="currentColor"/>
<rect x="4" y="14" width="2" height="2" fill="currentColor"/>
<rect x="2" y="16" width="2" height="2" fill="currentColor"/>
<!-- Dots inside -->
<rect x="6" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
<rect x="9" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
<rect x="12" y="7" width="2" height="2" fill="currentColor" opacity="0.5"/>
</svg>
</button>
</div>
</div>
<!-- Voice FAB Button (hidden) -->
<button
v-show="false"
class="voice-fab"
:class="{ active: showVoice, 'sheet-open': showVoice || showTranscriptDebug, 'ptt-active': voicePTTActive, 'keyboard-visible': keyboardVisible }"
@click="handleVoiceFabClick"
@touchstart="handleVoiceFabTouchStart"
@touchend="handleVoiceFabTouchEnd"
@touchcancel="handleVoiceFabTouchEnd"
title="Voice Input (mantén presionado para PTT)"
>
<svg 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 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>
<!-- Floating Response (Agent UI messages) -->
<FloatingResponse ref="responseRef" />
<!-- Floating Voice Input -->
<FloatingVoice ref="voiceRef" v-model="showVoice" />
<!-- Floating Transcript Debug -->
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
<!-- Global Hooks Approval Modal -->
<HooksApprovalModal />
<!-- Tauri Server Config Dialog -->
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />
<!-- Debug Console Panel -->
<Teleport to="body">
<Transition name="debug-slide">
<div v-if="showDebugConsole" class="debug-console">
<div class="debug-header">
<span>Debug Console ({{ debugLogs.length }})</span>
<div class="debug-actions">
<button @click="copyDebugLogs" title="Copy all">Copy</button>
<button @click="clearDebugLogs" title="Clear">Clear</button>
<button @click="showDebugConsole = false" title="Close">×</button>
</div>
</div>
<div class="debug-logs">
<div
v-for="(log, i) in debugLogs"
:key="i"
class="debug-log"
:class="log.type"
@click="navigator.clipboard.writeText(log.message)"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-type">{{ log.type }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
<div v-if="!debugLogs.length" class="debug-empty">No logs yet</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
background: var(--bg-primary);
}
/* PiP window: no header, no chrome, full bleed */
.app-container.pip-window {
border-radius: 8px;
overflow: hidden;
}
.app-container.pip-window .app-main {
border-radius: 8px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
padding-top: calc(0.5rem + var(--sat, env(safe-area-inset-top, 0px)));
padding-left: calc(1rem + var(--sal, env(safe-area-inset-left, 0px)));
padding-right: calc(1rem + var(--sar, env(safe-area-inset-right, 0px)));
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
min-height: 40px;
-webkit-app-region: drag;
app-region: drag;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* ── Compact header (WCO + manual toggle) ── */
.wco-header,
.wco-header.app-header {
height: 32px;
min-height: 32px;
max-height: 32px;
padding: 0 0.5rem;
padding-right: 0;
border-bottom: none;
overflow: visible;
}
.wco-header .header-left { gap: 0.35rem; overflow: visible; }
.wco-header .header-right { gap: 0.2rem; }
.wco-header .toolbar-toggle {
width: 22px;
height: 22px;
border-radius: 4px;
}
.wco-header .toolbar-toggle svg { width: 12px; height: 12px; }
.wco-header .header-sep { font-size: 0.65rem; }
.wco-header .header-canvas-name { font-size: 0.65rem; max-width: 100px; }
.wco-header .header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
.wco-header :deep(.pwa-banner) { display: none; }
.wco-header .debug-btn {
padding: 1px 4px;
font-size: 8px;
border-radius: 3px;
gap: 2px;
}
.wco-header .debug-btn svg { width: 10px; height: 10px; }
.wco-header .log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
.wco-header .refresh-btn {
width: 22px;
height: 22px;
border-radius: 4px;
}
.wco-header .refresh-btn svg { width: 12px; height: 12px; }
/* TorchButton compact via :deep */
.wco-header :deep(.trigger-split) { border-radius: 4px; font-size: 0.65rem; }
.wco-header :deep(.trigger-main) { padding: 0.1rem 0.25rem 0.1rem 0.35rem; gap: 0.25rem; }
.wco-header :deep(.trigger-chevron) { padding: 0.1rem 0.25rem; }
.wco-header :deep(.status-dot) { width: 5px; height: 5px; }
.wco-header :deep(.chevron) { width: 10px; height: 10px; }
.wco-header :deep(.trigger-name) { max-width: 60px; font-size: 0.65rem; }
/* Window Controls Overlay — real PWA titlebar */
@media (display-mode: window-controls-overlay) {
.app-header {
position: fixed;
top: env(titlebar-area-y, 0);
left: env(titlebar-area-x, 0);
width: env(titlebar-area-width, 100%);
height: env(titlebar-area-height, 32px);
min-height: unset;
max-height: env(titlebar-area-height, 32px);
padding: 0 0.5rem;
z-index: 10000;
border-bottom: none;
box-sizing: border-box;
background: var(--bg-primary);
}
.app-container {
padding-top: env(titlebar-area-height, 32px);
}
/* All the same compacting as .wco-header */
.header-left { gap: 0.35rem; }
.header-right { gap: 0.2rem; }
.toolbar-toggle { width: 22px; height: 22px; border-radius: 4px; }
.toolbar-toggle svg { width: 12px; height: 12px; }
.header-sep { font-size: 0.65rem; }
.header-canvas-name { font-size: 0.65rem; max-width: 100px; }
.header-canvas-badge { font-size: 0.5rem; padding: 0 0.2rem; }
.debug-btn { padding: 1px 4px; font-size: 8px; border-radius: 3px; gap: 2px; }
.debug-btn svg { width: 10px; height: 10px; }
.log-count { font-size: 7px; padding: 0 2px; min-width: 10px; }
.refresh-btn { width: 22px; height: 22px; border-radius: 4px; }
.refresh-btn svg { width: 12px; height: 12px; }
}
/* Tiny hidden toggle dot */
.wco-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border-color);
opacity: 0.18;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
}
.wco-dot:hover {
opacity: 0.5;
transform: scale(1.8);
}
.wco-dot.on {
background: #6366f1;
opacity: 0.6;
box-shadow: 0 0 4px rgba(99, 102, 241, 0.5);
}
.toolbar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
}
.toolbar-toggle:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
}
.toolbar-toggle svg {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.toolbar-toggle.collapsed svg {
transform: scaleX(-1);
}
.toolbar-toggle.collapsed {
color: var(--accent, #6366f1);
border-color: rgba(99, 102, 241, 0.3);
background: rgba(99, 102, 241, 0.08);
}
.header-sep {
color: var(--text-muted);
font-size: 0.85rem;
opacity: 0.4;
}
.header-canvas-name {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.header-canvas-badge {
padding: 0.0625rem 0.375rem;
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
font-size: 0.625rem;
font-weight: 500;
border-radius: 999px;
}
.refresh-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.refresh-btn:hover {
background: var(--bg-hover);
color: var(--accent);
border-color: var(--accent);
}
.refresh-btn:active {
transform: rotate(180deg);
}
/* Approval badge button */
.approval-badge-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
width: auto;
min-width: 28px;
height: 28px;
padding: 0 6px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: #f59e0b;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.approval-badge-btn:hover {
background: var(--bg-hover);
border-color: #f59e0b;
}
.approval-badge-btn.active {
background: rgba(245, 158, 11, 0.15);
border-color: #f59e0b;
}
.approval-badge-btn.pulse {
animation: approval-badge-pulse 2s ease-in-out infinite;
}
.approval-count {
background: #ef4444;
color: white;
font-size: 9px;
font-weight: 700;
padding: 0 4px;
border-radius: 8px;
min-width: 14px;
text-align: center;
line-height: 1.4;
}
@keyframes approval-badge-pulse {
0%, 100% { box-shadow: none; }
50% { box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
}
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Page transitions */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateX(20px);
}
.page-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* Voice FAB */
.voice-fab {
position: fixed;
bottom: 20px;
left: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9998;
/* Prevent text selection and touch gestures */
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.voice-fab:hover {
transform: scale(1.08);
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.5);
}
.voice-fab.active {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4);
}
.voice-fab.active:hover {
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.5);
}
/* Voice FAB PTT active - recording in progress */
.voice-fab.ptt-active {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
box-shadow: 0 0 30px rgba(249, 115, 22, 0.7);
transform: scale(1.15);
animation: ptt-pulse 0.5s ease-in-out infinite;
}
@keyframes ptt-pulse {
0%, 100% { box-shadow: 0 0 30px rgba(249, 115, 22, 0.7); }
50% { box-shadow: 0 0 50px rgba(249, 115, 22, 0.9); }
}
/* Transcript Debug FAB wrapper — holds button + terminal stack */
.transcript-fab-wrap {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10000;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
/* Transcript Debug FAB — pixel art night ocean, elevated */
.transcript-fab {
position: relative;
width: 44px;
height: 44px;
border-radius: 0;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44' shape-rendering='crispEdges'%3E%3Crect width='44' height='8' fill='%23050a14'/%3E%3Crect y='8' width='44' height='4' fill='%23061020'/%3E%3Crect y='12' width='44' height='4' fill='%23071828'/%3E%3Crect y='16' width='44' height='4' fill='%23081e30'/%3E%3Crect y='20' width='44' height='4' fill='%23092438'/%3E%3Crect y='24' width='44' height='4' fill='%230a2a3e'/%3E%3Crect y='28' width='44' height='4' fill='%23082030'/%3E%3Crect y='32' width='44' height='4' fill='%23061828'/%3E%3Crect y='36' width='44' height='8' fill='%231a1810'/%3E%3Crect x='34' y='2' width='3' height='3' fill='%23e8e4c8' opacity='0.6'/%3E%3Crect x='35' y='1' width='2' height='1' fill='%23e8e4c8' opacity='0.35'/%3E%3Crect x='35' y='5' width='1' height='1' fill='%23c8c4a8' opacity='0.2'/%3E%3Crect x='6' y='2' width='1' height='1' fill='%23c8d8f0' opacity='0.5'/%3E%3Crect x='14' y='4' width='1' height='1' fill='%23c8d8f0' opacity='0.35'/%3E%3Crect x='24' y='1' width='1' height='1' fill='%23c8d8f0' opacity='0.4'/%3E%3Crect x='40' y='6' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='18' y='7' width='1' height='1' fill='%23c8d8f0' opacity='0.25'/%3E%3Crect x='2' y='9' width='1' height='1' fill='%23c8d8f0' opacity='0.2'/%3E%3Crect x='30' y='3' width='1' height='1' fill='%23c8d8f0' opacity='0.3'/%3E%3Crect x='10' y='6' width='1' height='1' fill='%23fde68a' opacity='0.2'/%3E%3Crect x='3' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='9' y='11' width='8' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='17' y='10' width='4' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='25' y='11' width='10' height='2' fill='%230284c7' opacity='0.12'/%3E%3Crect x='35' y='10' width='6' height='2' fill='%230ea5e9' opacity='0.15'/%3E%3Crect x='3' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='17' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='35' y='10' width='2' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='10' y='18' width='3' height='2' fill='%23f97316' opacity='0.35'/%3E%3Crect x='9' y='19' width='1' height='1' fill='%23fdba74' opacity='0.2'/%3E%3Crect x='30' y='24' width='2' height='1' fill='%23818cf8' opacity='0.3'/%3E%3Crect x='32' y='24' width='1' height='1' fill='%23a5b4fc' opacity='0.2'/%3E%3Crect x='20' y='30' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='36' y='22' width='1' height='1' fill='%23c8d8f0' opacity='0.1'/%3E%3Crect x='6' y='26' width='1' height='1' fill='%23c8d8f0' opacity='0.08'/%3E%3Crect x='4' y='36' width='6' height='5' fill='%23052e1e' opacity='0.7'/%3E%3Crect x='5' y='32' width='2' height='4' fill='%23064e33' opacity='0.5'/%3E%3Crect x='8' y='34' width='2' height='2' fill='%23059669' opacity='0.35'/%3E%3Crect x='30' y='38' width='5' height='4' fill='%23052e1e' opacity='0.6'/%3E%3Crect x='32' y='35' width='2' height='3' fill='%23064e33' opacity='0.45'/%3E%3Crect x='18' y='39' width='4' height='3' fill='%23500e28' opacity='0.45'/%3E%3Crect x='19' y='37' width='2' height='2' fill='%23701838' opacity='0.35'/%3E%3Crect x='14' y='40' width='2' height='2' fill='%23052e1e' opacity='0.5'/%3E%3Crect x='38' y='40' width='3' height='2' fill='%231a1810' opacity='0.6'/%3E%3Crect x='24' y='42' width='2' height='1' fill='%23c8b060' opacity='0.15'/%3E%3Crect x='10' y='42' width='2' height='1' fill='%23c8b060' opacity='0.12'/%3E%3C/svg%3E");
color: #0ea5e9;
border: 1px solid rgba(14, 165, 233, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.5),
0 6px 16px rgba(0, 0, 0, 0.6),
0 12px 28px rgba(0, 0, 0, 0.4);
filter: grayscale(1) brightness(0.6);
transition: all 0.2s ease;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
image-rendering: pixelated;
pointer-events: auto;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.transcript-fab *,
.transcript-fab svg {
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
pointer-events: none;
}
.transcript-fab:not(.t1-active):hover {
transform: translateY(-2px);
border-color: rgba(14, 165, 233, 0.35);
filter: grayscale(0.5) brightness(0.8);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.5),
0 10px 24px rgba(0, 0, 0, 0.6),
0 0 14px rgba(14, 165, 233, 0.15);
color: #38bdf8;
}
.transcript-fab.active {
color: #67e8f9;
border-color: rgba(14, 165, 233, 0.3);
}
.transcript-fab.active:hover {
color: #a5f3fc;
}
.transcript-fab.t1-active {
border: 2px solid;
border-image: conic-gradient(
from var(--border-angle, 0deg),
rgba(34, 211, 238, 1),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 0.15),
rgba(99, 102, 241, 0.7),
rgba(34, 211, 238, 1)
) 1;
filter: none;
box-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
animation: border-spin 3s linear infinite;
}
@property --border-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes border-spin {
to { --border-angle: 360deg; }
}
.fab-button-area {
position: relative;
width: 44px;
height: 44px;
}
/* Bubble particles — very occasional */
.fab-bubble {
position: absolute;
width: 4px;
height: 4px;
background: rgba(14, 165, 233, 0.5);
border-radius: 50%;
pointer-events: none;
opacity: 0;
}
.fab-bubble.b1 {
left: 10px;
bottom: 36px;
animation: fab-bubble-rise 18s ease-in 2s infinite;
}
.fab-bubble.b2 {
left: 28px;
bottom: 38px;
animation: fab-bubble-rise 22s ease-in 9s infinite;
width: 3px;
height: 3px;
background: rgba(34, 211, 238, 0.45);
}
.fab-bubble.b3 {
left: 18px;
bottom: 34px;
animation: fab-bubble-rise 25s ease-in 16s infinite;
width: 5px;
height: 5px;
background: rgba(14, 165, 233, 0.4);
}
@keyframes fab-bubble-rise {
0%, 85%, 100% {
opacity: 0;
transform: translateY(0) translateX(0);
}
88% {
opacity: 0.7;
transform: translateY(-10px) translateX(2px);
}
92% {
opacity: 0.5;
transform: translateY(-24px) translateX(-1px);
}
96% {
opacity: 0.2;
transform: translateY(-40px) translateX(3px);
}
98% {
opacity: 0;
transform: translateY(-52px) translateX(1px);
}
}
@media (max-width: 768px) {
.voice-fab {
bottom: 80px;
left: 16px;
width: 44px;
height: 44px;
}
.transcript-fab-wrap {
bottom: 80px;
right: 16px;
}
.transcript-fab-wrap .fab-button-area {
width: 40px;
height: 40px;
}
.transcript-fab-wrap .transcript-fab {
width: 40px;
height: 40px;
}
}
/* Mobile: FABs above bottom sheets */
@media (max-width: 1024px) and (pointer: coarse) {
.voice-fab {
z-index: 10001;
transition: bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s ease;
}
.transcript-fab-wrap {
z-index: 10001;
}
.voice-fab.sheet-open {
bottom: calc(15vh + 100px);
}
.voice-fab.keyboard-visible {
bottom: 35vh;
}
.voice-fab.keyboard-visible.sheet-open {
bottom: 45vh;
}
}
/* Server Config Button (Tauri) */
.server-config-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.server-config-btn:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
}
/* Debug Console Button */
.debug-btn {
display: flex;
align-items: center;
gap: 3px;
padding: 3px 6px;
background: rgba(100, 100, 100, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 5px;
color: #888;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
}
.debug-btn:hover {
background: rgba(100, 100, 100, 0.3);
color: #aaa;
}
.debug-btn.active {
background: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.5);
color: #60a5fa;
}
.log-count {
background: #ef4444;
color: white;
font-size: 8px;
padding: 1px 4px;
border-radius: 10px;
min-width: 14px;
text-align: center;
line-height: 1.2;
}
/* Debug Console Panel */
.debug-console {
position: fixed;
top: 50px;
left: 10px;
right: 10px;
bottom: 80px;
background: rgba(20, 20, 25, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
z-index: 10002;
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.debug-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px 12px 0 0;
color: #ddd;
font-size: 12px;
font-weight: 600;
}
.debug-actions {
display: flex;
gap: 8px;
}
.debug-actions button {
padding: 4px 10px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
color: #aaa;
font-size: 11px;
cursor: pointer;
}
.debug-actions button:hover {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.debug-logs {
flex: 1;
overflow-y: auto;
padding: 8px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
}
.debug-log {
display: flex;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.debug-log:hover {
background: rgba(255, 255, 255, 0.05);
}
.debug-log:active {
background: rgba(59, 130, 246, 0.2);
}
.log-time {
color: #666;
flex-shrink: 0;
}
.log-type {
width: 40px;
flex-shrink: 0;
font-weight: 600;
}
.debug-log.log .log-type { color: #888; }
.debug-log.warn .log-type { color: #f59e0b; }
.debug-log.error .log-type { color: #ef4444; }
.log-msg {
color: #ccc;
word-break: break-all;
}
.debug-log.warn .log-msg { color: #fcd34d; }
.debug-log.error .log-msg { color: #fca5a5; }
.debug-empty {
color: #666;
text-align: center;
padding: 40px;
}
/* Debug slide animation */
.debug-slide-enter-active,
.debug-slide-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.debug-slide-enter-from,
.debug-slide-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>