- 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
1305 lines
40 KiB
Vue
1305 lines
40 KiB
Vue
<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>
|