Files
agent-ui/frontend/src/components/FloatingTranscriptDebug.vue
josedario87 220d595568 feat: voice mic, pixel life layer, enhanced transcript-debug UX
VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble
with voice display, AgentBadge terminal switcher, ChatContainer voice integration,
FloatingTranscriptDebug ocean life enhancements, and terminal registry support.
Remove traefik config.
2026-02-20 12:12:53 -06:00

1637 lines
58 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// ============================================================================
// TRANSCRIPT DEBUG STATE (composable)
// ============================================================================
const {
selectedAgent,
sessions,
selectedSessionId,
conversation,
loading,
error,
isRealtime,
processing,
ephemeral,
terminalReady,
openTerminals,
activeTerminalSessionId,
init,
switchAgent,
selectSession,
createNewSession,
switchToTerminal,
closeTerminal,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
const voice = useVoiceInput({ language: 'es-419' })
const {
isRecording: voiceRecording,
transcript: voiceTranscript,
interimTranscript: voiceInterim,
voiceMode,
whisperStatus,
audioDevices,
selectedDeviceId,
lastAudioUrl,
isPlayingAudio,
} = voice
const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' },
{ id: 'claude', label: 'Claude' }
]
const showSelector = ref(false)
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
let initialized = false
// ============================================================================
// CHROME VISIBILITY (idle mode: hide UI chrome when not interacting)
// ============================================================================
const isHovered = ref(false)
const isFocusWithin = ref(false)
const showChrome = computed(() =>
isHovered.value || isFocusWithin.value || isDragging.value ||
isResizing.value || showSelector.value
)
// ============================================================================
// OCEAN LIFE STATE
// ============================================================================
const currentHour = ref(new Date().getHours())
const isNightTime = computed(() => currentHour.value >= 20 || currentHour.value < 6)
const isNoonTime = computed(() => currentHour.value >= 11 && currentHour.value <= 13)
const isDawnTime = computed(() => currentHour.value >= 5 && currentHour.value <= 7)
const isFridayTime = computed(() => new Date().getDay() === 5)
const isMidnightTime = computed(() => currentHour.value === 0)
const showComet = ref(false)
const showWhale = ref(false)
const showBottle = ref(false)
const showLeviathan = ref(false)
const oceanSeeds = Array.from({ length: 20 }, () => Math.random())
let oceanLifeTimer: ReturnType<typeof setInterval> | null = null
function tickOceanLife() {
currentHour.value = new Date().getHours()
if (!showComet.value && Math.random() < 0.04) {
showComet.value = true
setTimeout(() => { showComet.value = false }, 3000)
}
if (!showWhale.value && Math.random() < 0.07) {
showWhale.value = true
setTimeout(() => { showWhale.value = false }, 95000)
}
if (!showBottle.value && Math.random() < 0.025) {
showBottle.value = true
setTimeout(() => { showBottle.value = false }, 125000)
}
if (!showLeviathan.value && Math.random() < 0.008) {
showLeviathan.value = true
setTimeout(() => { showLeviathan.value = false }, 310000)
}
}
// ============================================================================
// DRAG STATE
// ============================================================================
const windowRef = ref<HTMLElement | null>(null)
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: 480, h: 600 })
// Zoom level for content scaling
const zoom = ref(1)
// Size mode: pin (small, anchored to FAB), medium (default), large
type SizeMode = 'pin' | 'medium' | 'large'
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium')
// Readability overlay opacity
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
function setOverlayOpacity(val: number) {
overlayOpacity.value = val
localStorage.setItem('transcript-overlay-opacity', String(val))
}
// Input textarea max lines
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
function setInputMaxLines(val: number) {
inputMaxLines.value = val
localStorage.setItem('transcript-input-max-lines', String(val))
}
// Force mobile (bottom sheet) mode on desktop
const forceMobile = ref(false)
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
function toggleForceMobile() {
if (isMobile.value) return // already native mobile
forceMobile.value = !forceMobile.value
if (forceMobile.value) {
// Enter mobile: set sheet height based on current sizeMode
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
hasCustomPosition.value = false
}
}
function cycleSizeMode() {
const modes: SizeMode[] = ['pin', 'medium', 'large']
const i = modes.indexOf(sizeMode.value)
sizeMode.value = modes[(i + 1) % modes.length]
localStorage.setItem('transcript-size-mode', sizeMode.value)
hasCustomPosition.value = false
// In mobile mode, also update sheet height
if (effectiveMobile.value) {
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
}
}
// Mobile bottom sheet state
const isMobile = ref(false)
const sheetHeight = ref(55)
// Virtual keyboard detection
const keyboardVisible = ref(false)
const keyboardHeight = ref(0)
function onVisualViewportResize() {
if (!window.visualViewport) return
const vv = window.visualViewport
const kbH = window.innerHeight - (vv.offsetTop + vv.height)
keyboardHeight.value = Math.max(0, kbH)
keyboardVisible.value = kbH > 100
}
// ============================================================================
// MOBILE DETECTION
// ============================================================================
function checkMobile() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const isSmallScreen = window.innerWidth <= 1024
isMobile.value = isTouchDevice && isSmallScreen
}
// ============================================================================
// WINDOW DRAG
// ============================================================================
function startDrag(e: MouseEvent) {
if (effectiveMobile.value) 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 || 480
const h = windowRef.value?.offsetHeight || 600
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(e.clientX - dragOffset.value.x, maxX)),
y: Math.max(minY, Math.min(e.clientY - dragOffset.value.y, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// ============================================================================
// WINDOW RESIZE
// ============================================================================
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
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(360, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(300, 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)
}
// ============================================================================
// COMPUTED STYLE
// ============================================================================
const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
pin: { w: 240, h: 300 },
medium: { w: 480, h: 600 },
large: { w: 800, h: 760 }
}
const windowStyle = computed((): Record<string, string> => {
if (effectiveMobile.value) {
const isFullScreen = sheetHeight.value >= 100
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const headerH = headerEl ? headerEl.offsetHeight : 40
const style: Record<string, string> = {
position: 'fixed',
left: '0',
right: '0',
width: '100%',
}
if (keyboardVisible.value) {
// Body is position:fixed so no browser auto-scroll — simple offset works
style.bottom = `${keyboardHeight.value}px`
const available = window.innerHeight - keyboardHeight.value - headerH
if (isFullScreen) {
style.height = `${Math.max(0, available)}px`
} else {
const originalH = window.innerHeight * sheetHeight.value / 100
style.height = `${Math.max(0, Math.min(originalH, available))}px`
}
} else {
// No keyboard
style.bottom = '0'
if (isFullScreen) {
style.top = `${headerH}px`
style.height = 'auto'
} else {
style.height = `${sheetHeight.value}dvh`
}
}
return style
}
const preset = sizeModePresets[sizeMode.value]
// Custom position from dragging (uses current resize size or preset)
if (hasCustomPosition.value) {
const w = sizeMode.value === 'medium' ? size.value.w : preset.w
const h = sizeMode.value === 'medium' ? size.value.h : preset.h
return {
width: `${w}px`,
height: `${h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
}
// Pin: anchored to FAB button (bottom-left corner aligned)
if (sizeMode.value === 'pin') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '20px',
left: '80px'
}
}
// Large: centered-ish, generous size
if (sizeMode.value === 'large') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '16px',
left: '90px'
}
}
// Medium (default)
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '16px',
left: '90px'
}
})
// ============================================================================
// ACTIONS
// ============================================================================
function close() {
isOpen.value = false
showSelector.value = false
}
function openAtCursor(x: number, y: number) {
if (effectiveMobile.value) {
isOpen.value = !isOpen.value
return
}
if (isOpen.value) {
isOpen.value = false
return
}
const preset = sizeModePresets[sizeMode.value]
const w = sizeMode.value === 'medium' ? size.value.w : preset.w
const h = sizeMode.value === 'medium' ? size.value.h : preset.h
const pad = 8
const vw = window.innerWidth
const vh = window.innerHeight
// Respect app header bar (~40px + safe-area)
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const topBarrier = headerEl ? headerEl.getBoundingClientRect().bottom + pad : pad + 40
// Cursor at bottom-center of window: window extends upward from cursor
let left = x - w / 2
let top = y - h
// Clamp horizontally
left = Math.max(pad, Math.min(left, vw - w - pad))
// Clamp vertically: never above header, never below viewport
top = Math.max(topBarrier, Math.min(top, vh - h - pad))
position.value = { x: left, y: top }
hasCustomPosition.value = true
isOpen.value = true
nextTick(() => {
windowRef.value?.querySelector<HTMLTextAreaElement>('.input-field')?.focus()
})
}
defineExpose({ openAtCursor })
function handleAgentSwitch(agent: AgentName) {
switchAgent(agent)
}
function handleSessionSelect(sessionId: string) {
selectSession(sessionId)
showSelector.value = false
}
function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message)
}
function handleCreateSession() {
createNewSession()
}
// ============================================================================
// WATCHERS
// ============================================================================
watch(isOpen, async (open) => {
if (open && !initialized) {
initialized = true
await nextTick()
init()
}
})
// ============================================================================
// ZOOM KEYBOARD HANDLER
// ============================================================================
function handleZoomKey(e: KeyboardEvent) {
if (!isOpen.value || !e.ctrlKey) return
if (e.key === '+' || e.key === '=') {
e.preventDefault()
zoom.value = Math.min(2, +(zoom.value + 0.1).toFixed(1))
} else if (e.key === '-') {
e.preventDefault()
zoom.value = Math.max(0.5, +(zoom.value - 0.1).toFixed(1))
}
}
// ============================================================================
// LIFECYCLE
// ============================================================================
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleZoomKey)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onVisualViewportResize)
}
oceanLifeTimer = setInterval(tickOceanLife, 20000)
tickOceanLife()
await voice.init()
})
onBeforeUnmount(() => {
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
disconnectRealtime()
voice.cleanup()
document.removeEventListener('keydown', handleZoomKey)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile)
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
})
</script>
<template>
<Teleport to="body">
<Transition name="win-slide">
<div
v-show="isOpen"
ref="windowRef"
class="aero-win"
:class="{
dragging: isDragging,
resizing: isResizing,
mobile: effectiveMobile,
'chrome-visible': showChrome,
'selector-open': showSelector
}"
:style="windowStyle"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocusWithin = true"
@focusout="isFocusWithin = false"
>
<div class="glass" :style="{ zoom: zoom !== 1 ? zoom : undefined }">
<!-- Thin drag edge (replaces full titlebar drag) -->
<div
v-if="!effectiveMobile"
class="drag-edge"
@mousedown="startDrag"
></div>
<!-- Titlebar -->
<div class="titlebar">
<!-- Ocean Life Ecosystem -->
<div class="ocean-life">
<!-- Always visible -->
<i class="cr fish-school" :style="{ animationDelay: `-${oceanSeeds[0] * 10}s` }"></i>
<i class="cr fish-school-2" :style="{ animationDelay: `-${oceanSeeds[1] * 14}s` }"></i>
<i class="cr dolphin" :style="{ animationDelay: `-${oceanSeeds[2] * 12}s` }"></i>
<i class="cr jellyfish" :style="{ animationDelay: `-${oceanSeeds[3] * 25}s` }"></i>
<i class="cr seahorse" :style="{ animationDelay: `-${oceanSeeds[4] * 35}s` }"></i>
<i class="cr turtle" :style="{ animationDelay: `-${oceanSeeds[5] * 50}s` }"></i>
<i class="cr pirate-ship" :style="{ animationDelay: `-${oceanSeeds[6] * 180}s` }"></i>
<i class="cr bubble b1" :style="{ animationDelay: `-${oceanSeeds[7] * 6}s` }"></i>
<i class="cr bubble b2" :style="{ animationDelay: `-${oceanSeeds[8] * 9}s` }"></i>
<i class="cr bubble b3" :style="{ animationDelay: `-${oceanSeeds[9] * 11}s` }"></i>
<!-- Night only -->
<template v-if="isNightTime">
<i class="cr satellite" :style="{ animationDelay: `-${oceanSeeds[10] * 15}s` }"></i>
<i class="cr anglerfish" :style="{ animationDelay: `-${oceanSeeds[11] * 40}s` }"></i>
<i class="cr biolum bl1" :style="{ animationDelay: `-${oceanSeeds[12] * 3.5}s` }"></i>
<i class="cr biolum bl2" :style="{ animationDelay: `-${oceanSeeds[13] * 4.5}s` }"></i>
<i class="cr biolum bl3" :style="{ animationDelay: `-${oceanSeeds[14] * 5.5}s` }"></i>
</template>
<!-- Rare random -->
<i v-if="showComet" class="cr comet"></i>
<i v-if="showWhale" class="cr whale" :style="{ animationDelay: `-${oceanSeeds[15] * 90}s` }"></i>
<i v-if="showBottle" class="cr bottle" :style="{ animationDelay: `-${oceanSeeds[16] * 120}s` }"></i>
<i v-if="showLeviathan" class="cr leviathan" :style="{ animationDelay: `-${oceanSeeds[17] * 300}s` }"></i>
<!-- Time-of-day -->
<i v-if="isNoonTime" class="cr sun-ray"></i>
<i v-if="isDawnTime" class="cr dawn-glow"></i>
<!-- Easter eggs -->
<i v-if="isFridayTime" class="cr party-fish" :style="{ animationDelay: `-${oceanSeeds[18] * 8}s` }"></i>
<i v-if="isMidnightTime" class="cr ghost-ship" :style="{ animationDelay: `-${oceanSeeds[19] * 240}s` }"></i>
</div>
<div class="left">
<AgentBadge
v-if="selectedAgent"
:agent="selectedAgent"
:connected="isRealtime"
:terminals="openTerminals"
:active-session-id="activeTerminalSessionId"
@switch-terminal="switchToTerminal"
@close-terminal="closeTerminal"
/>
</div>
<div class="window-controls">
<button
v-if="!isMobile"
@click.stop="toggleForceMobile"
class="mobile-btn"
:class="{ active: forceMobile }"
title="Toggle mobile panel"
>
<!-- Phone/panel-from-bottom icon -->
<svg width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="0" width="6" height="10" fill="currentColor" opacity="0.25"/>
<rect x="3" y="1" width="4" height="7" fill="currentColor"/>
<rect x="4" y="9" width="2" height="1" fill="currentColor" opacity="0.5"/>
</svg>
</button>
<button @click.stop="cycleSizeMode" class="size-btn" :title="`Size: ${sizeMode}`">
<!-- Pin icon: small square bottom-left -->
<svg v-if="sizeMode === 'pin'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="6" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Medium icon: medium square centered -->
<svg v-else-if="sizeMode === 'medium'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="2" width="6" height="6" fill="currentColor" opacity="0.35"/>
<rect x="3" y="3" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Large icon: full square -->
<svg v-else width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="0" width="10" height="10" fill="currentColor" opacity="0.35"/>
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
</svg>
</button>
<button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
<template v-else>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</template>
</svg>
</button>
<button @click.stop="showSelector = !showSelector" :class="{ active: showSelector }" title="Agent/Session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.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-2 2 2 2 0 0 1-2-2v-.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 0 2 2 0 0 1 0-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-2-2 2 2 0 0 1 2-2h.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 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.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 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button class="x" @click="close" title="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>
<!-- Error -->
<div v-if="error" class="error-bar">{{ error }}</div>
<!-- Content -->
<div class="content">
<AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<ChatContainer
ref="chatRef"
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
:sessions="sessions"
:selected-session-id="selectedSessionId"
:sessions-loading="loading"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:overlay-opacity="overlayOpacity"
:input-max-lines="inputMaxLines"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
@start-recording="voice.startRecording()"
@stop-recording="voice.stopRecording()"
@set-voice-mode="voice.setMode($event)"
@select-microphone="voice.selectMicrophone($event)"
@play-last-audio="voice.playLastAudio()"
@update:overlay-opacity="setOverlayOpacity"
@update:input-max-lines="setInputMaxLines"
/>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Select a session to begin</span>
<small>{{ sessions.length }} sessions available</small>
</div>
</div>
<!-- Resize handle -->
<div v-if="!effectiveMobile" class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.aero-win {
position: fixed;
min-width: 200px;
min-height: 200px;
z-index: 9999;
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease, bottom 0.3s ease;
}
/* ── Dynamic glow from ocean background ── */
.aero-win::before {
content: '';
position: absolute;
inset: -12px;
z-index: -1;
border-radius: 18px;
background: linear-gradient(
180deg,
rgba(0, 12, 35, 0.35) 0%,
rgba(0, 30, 65, 0.30) 25%,
rgba(4, 52, 78, 0.35) 50%,
rgba(6, 58, 72, 0.30) 70%,
rgba(18, 50, 45, 0.35) 100%
);
filter: blur(18px);
opacity: 0.7;
transition: opacity 0.5s ease, filter 0.5s ease;
pointer-events: none;
}
.aero-win:hover::before,
.aero-win.chrome-visible::before {
opacity: 1;
filter: blur(22px);
}
.aero-win.dragging::before,
.aero-win.resizing::before {
transition: none;
}
.aero-win.dragging,
.aero-win.resizing {
transition: none;
}
.glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.05);
box-shadow:
0 0 0 1px rgba(0,0,0,0.6),
0 16px 56px rgba(0,0,0,0.6),
0 4px 16px rgba(0,0,0,0.4);
overflow: hidden;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
/* ══════════════════════════════════════════════════════════════════════════
IDLE MODE: When not hovering/focused, hide all chrome.
Only the messages + ocean background fill the entire window.
══════════════════════════════════════════════════════════════════════════ */
/* Transition all chrome elements smoothly */
.titlebar,
.error-bar,
.resize-handle {
transition: opacity 0.35s ease, max-height 0.35s ease, padding 0.35s ease;
}
/* Idle: hide drag edge */
.aero-win:not(.chrome-visible) .drag-edge {
pointer-events: none;
}
/* Idle: slide titlebar up and fade out */
.aero-win:not(.chrome-visible) .titlebar {
opacity: 0;
transform: translateY(-100%);
pointer-events: none;
}
/* Idle: hide error bar */
.aero-win:not(.chrome-visible) .error-bar {
opacity: 0;
max-height: 0;
padding: 0;
border-bottom-width: 0;
overflow: hidden;
}
/* Idle: hide resize handle */
.aero-win:not(.chrome-visible) .resize-handle {
opacity: 0;
pointer-events: none;
}
/* Chat-header: only visible when settings/selector is open */
.aero-win:not(.selector-open) .content :deep(.chat-header) {
opacity: 0 !important;
transform: translateY(-150%) !important;
pointer-events: none !important;
}
/* Idle: slide user-input down and fade out (only if empty) */
.aero-win:not(.chrome-visible) .content :deep(.user-input) {
opacity: 0 !important;
transform: translateY(100%) !important;
pointer-events: none !important;
}
/* Keep user-input visible when textarea has text */
.aero-win:not(.chrome-visible) .content :deep(.user-input:has(.input-field:not(:placeholder-shown))) {
opacity: 1 !important;
transform: none !important;
pointer-events: auto !important;
}
/* Idle: hide status bar */
.aero-win:not(.chrome-visible) .content :deep(.status-bar) {
opacity: 0 !important;
transform: translateY(100%) !important;
pointer-events: none !important;
}
/* Keep status-bar visible when input has text */
.aero-win:not(.chrome-visible) .content :deep(.status-bar:has(~ .user-input .input-field:not(:placeholder-shown))) {
opacity: 1 !important;
transform: none !important;
pointer-events: auto !important;
}
/* Idle: also hide selection bar */
.aero-win:not(.chrome-visible) .content :deep(.selection-bar) {
opacity: 0 !important;
pointer-events: none !important;
}
/* Idle: softer glass border + dimmed glow */
.aero-win:not(.chrome-visible) .glass {
border-color: rgba(255,255,255,0.02);
box-shadow:
0 0 0 1px rgba(0,0,0,0.3),
0 8px 32px rgba(0,0,0,0.4);
}
.aero-win:not(.chrome-visible)::before {
opacity: 0.4;
filter: blur(14px);
}
/* Smooth transitions for chrome show/hide */
.content :deep(.chat-header) {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
.content :deep(.user-input) {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
.content :deep(.status-bar) {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
/* ── Drag edge: thin strip at top for window dragging ── */
.drag-edge {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
z-index: 11;
cursor: grab;
}
.drag-edge:active {
cursor: grabbing;
}
.aero-win.dragging .drag-edge {
cursor: grabbing;
}
/* ── Titlebar: absolute overlay at top of .glass ── */
.titlebar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 5px 0 8px;
background:
/* Pixel art sea surface: sky, moon, waves, fish, bubbles */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='120' height='12' fill='%230a1628' opacity='0.5'/%3E%3Crect x='0' y='12' width='120' height='16' fill='%230c2d4a' opacity='0.45'/%3E%3Crect x='8' y='3' width='1' height='1' fill='white' opacity='0.3'/%3E%3Crect x='25' y='5' width='1' height='1' fill='white' opacity='0.22'/%3E%3Crect x='45' y='2' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='70' y='4' width='1' height='1' fill='white' opacity='0.28'/%3E%3Crect x='95' y='6' width='1' height='1' fill='white' opacity='0.22'/%3E%3Crect x='110' y='3' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='36' y='7' width='1' height='1' fill='%23fde68a' opacity='0.15'/%3E%3Crect x='85' y='2' width='3' height='3' fill='%23fef3c7' opacity='0.3'/%3E%3Crect x='86' y='1' width='2' height='1' fill='%23fef3c7' opacity='0.2'/%3E%3Crect x='86' y='5' width='2' height='1' fill='%23fde68a' opacity='0.15'/%3E%3Crect x='50' y='4' width='4' height='2' fill='%23475569' opacity='0.15'/%3E%3Crect x='51' y='3' width='2' height='1' fill='%23475569' opacity='0.1'/%3E%3Crect x='8' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='12' y='11' width='10' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='22' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='28' y='11' width='12' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='40' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='44' y='11' width='14' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='58' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='64' y='11' width='10' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='74' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='78' y='11' width='12' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='90' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='96' y='11' width='14' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='110' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='114' y='11' width='6' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='0' y='11' width='8' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='8' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='22' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='40' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='58' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='74' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='90' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='110' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='0' y='14' width='120' height='2' fill='%230369a1' opacity='0.18'/%3E%3Crect x='0' y='18' width='120' height='2' fill='%23075985' opacity='0.12'/%3E%3Crect x='0' y='22' width='120' height='2' fill='%230c4a6e' opacity='0.08'/%3E%3Crect x='15' y='16' width='3' height='1' fill='%23f97316' opacity='0.3'/%3E%3Crect x='14' y='16' width='1' height='1' fill='%23fb923c' opacity='0.2'/%3E%3Crect x='55' y='20' width='3' height='1' fill='%23818cf8' opacity='0.22'/%3E%3Crect x='58' y='20' width='1' height='1' fill='%23a78bfa' opacity='0.15'/%3E%3Crect x='100' y='17' width='3' height='1' fill='%2322c55e' opacity='0.22'/%3E%3Crect x='99' y='17' width='1' height='1' fill='%234ade80' opacity='0.15'/%3E%3Crect x='30' y='15' width='1' height='1' fill='white' opacity='0.1'/%3E%3Crect x='65' y='22' width='1' height='1' fill='white' opacity='0.08'/%3E%3Crect x='82' y='14' width='1' height='1' fill='white' opacity='0.1'/%3E%3C/svg%3E") repeat-x left top / auto 100%,
rgba(0, 8, 20, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,0.04);
user-select: none;
transition: opacity 0.35s ease, transform 0.35s ease;
animation: titlebar-ocean 8s linear infinite;
}
.left {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
color: rgba(255,255,255,0.6);
font: 500 10px/1 'Courier New', monospace;
}
.window-controls {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
}
.window-controls button {
width: 20px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 0;
color: rgba(255,255,255,0.4);
cursor: pointer;
transition: all 0.15s;
}
.window-controls button:hover {
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
}
.window-controls button.active {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.window-controls .mobile-btn {
color: #a78bfa;
}
.window-controls .mobile-btn:hover {
color: #c4b5fd;
background: rgba(167, 139, 250, 0.15);
border-color: rgba(167, 139, 250, 0.25);
}
.window-controls .mobile-btn.active {
background: rgba(167, 139, 250, 0.2);
border-color: rgba(167, 139, 250, 0.3);
color: #c4b5fd;
}
.window-controls .size-btn {
color: #0ea5e9;
}
.window-controls .size-btn:hover {
color: #38bdf8;
background: rgba(14, 165, 233, 0.15);
border-color: rgba(14, 165, 233, 0.25);
}
.window-controls button.x:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
/* ── Error Bar ── */
.error-bar {
padding: 4px 10px;
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
font-size: 10px;
font-family: 'Courier New', monospace;
flex-shrink: 0;
}
/* ── Content ── */
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
/* Background handled by AquaticBackground component */
}
/* Dark readability overlay: between ocean bg (z-index:0) and chat (z-index:1) */
.readability-overlay {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.aero-win.chrome-visible .readability-overlay {
opacity: 1;
}
/* Override ChatContainer backgrounds: glass-transparent */
.content :deep(.chat-container) {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
position: relative;
z-index: 1;
flex: 1 !important;
min-height: 0 !important;
}
/* Chat header: absolute overlay below titlebar, floats over messages */
.content :deep(.chat-header) {
position: absolute !important;
top: 28px !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 0.3rem 0.6rem !important;
}
/* Messages: fill entire container, pad top/bottom for overlaid titlebar+header / input */
.content :deep(.messages-scroll) {
background: transparent !important;
padding-top: 5rem !important;
padding-bottom: 5rem !important;
flex: 1 !important;
scrollbar-gutter: stable !important;
}
.content :deep(.meta-badge) {
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.4);
border-radius: 0;
font-family: 'Courier New', monospace;
}
.content :deep(.meta-badge.model) {
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
}
.content :deep(.meta-cwd),
.content :deep(.meta-duration),
.content :deep(.meta-count) {
color: rgba(255,255,255,0.25);
}
.content :deep(.copy-id-btn) {
color: rgba(255,255,255,0.25);
}
.content :deep(.copy-id-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.6);
}
.content :deep(.select-mode-btn) {
border-color: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.35);
border-radius: 0;
}
.content :deep(.select-mode-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.7);
}
.content :deep(.select-mode-btn.active) {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.35);
color: #c7d2fe;
}
/* Pixel art scrollbar */
.content :deep(.messages-scroll)::-webkit-scrollbar {
width: 8px;
transition: opacity 0.35s ease;
}
/* Idle: hide scrollbar visuals (gutter stays via scrollbar-gutter: stable) */
.aero-win:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-track {
background: transparent !important;
}
.aero-win:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background: transparent !important;
border-color: transparent !important;
}
.content :deep(.messages-scroll)::-webkit-scrollbar-track {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='8' fill='%230c2d4a' opacity='0.4'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23075985' opacity='0.15'/%3E%3Crect x='6' y='6' width='2' height='2' fill='%23075985' opacity='0.1'/%3E%3C/svg%3E") repeat;
}
.content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.3'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.15'/%3E%3C/svg%3E") repeat;
border: 1px solid rgba(14, 165, 233, 0.15);
}
.content :deep(.messages-scroll)::-webkit-scrollbar-thumb:hover {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.45'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.25'/%3E%3C/svg%3E") repeat;
border-color: rgba(14, 165, 233, 0.3);
}
/* Status bar: absolute overlay at very bottom */
.content :deep(.status-bar) {
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 4 !important;
background: rgba(0, 6, 18, 0.6) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
border-bottom: none !important;
padding: 0.15rem 0.5rem !important;
}
.content :deep(.status-id) {
color: rgba(255,255,255,0.35) !important;
}
.content :deep(.status-bar .copy-id-btn) {
color: rgba(255,255,255,0.25) !important;
}
.content :deep(.status-bar .copy-id-btn:hover) {
color: rgba(255,255,255,0.6) !important;
}
.content :deep(.status-bar .meta-badge) {
border-radius: 0 !important;
font-family: 'Courier New', monospace !important;
}
.content :deep(.status-bar .meta-badge.model) {
background: rgba(99, 102, 241, 0.1) !important;
color: #a5b4fc !important;
}
.content :deep(.status-bar .meta-badge.version) {
background: rgba(255,255,255,0.04) !important;
color: rgba(255,255,255,0.3) !important;
}
.content :deep(.status-bar .meta-count),
.content :deep(.status-bar .meta-duration) {
color: rgba(255,255,255,0.2) !important;
}
/* UserInput: absolute overlay above status bar */
.content :deep(.user-input) {
position: absolute !important;
bottom: 20px !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 0.3rem 0.5rem !important;
}
/* Dark overlay on input-container so text is readable */
.content :deep(.input-container) {
background: rgba(0, 6, 18, 0.8) !important;
border-color: rgba(14, 165, 233, 0.1) !important;
border-radius: 0 !important;
padding: 0.3rem 0.4rem !important;
}
.content :deep(.input-container:focus-within) {
border-color: rgba(14, 165, 233, 0.25) !important;
background: rgba(0, 6, 18, 0.85) !important;
}
.content :deep(.input-field) {
color: rgba(255,255,255,0.85);
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* Send button: pixel art daytime ocean, no border */
.content :deep(.send-btn) {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' shape-rendering='crispEdges'%3E%3Crect width='28' height='6' fill='%2387ceeb'/%3E%3Crect y='6' width='28' height='4' fill='%2356b3d9'/%3E%3Crect y='10' width='28' height='4' fill='%232d9abf'/%3E%3Crect y='14' width='28' height='4' fill='%231a7fa5'/%3E%3Crect y='18' width='28' height='4' fill='%23106888'/%3E%3Crect y='22' width='28' height='6' fill='%23c2b280'/%3E%3Crect x='24' y='4' width='3' height='3' fill='%23fffde0' opacity='0.8'/%3E%3Crect x='25' y='3' width='2' height='1' fill='%23fffde0' opacity='0.5'/%3E%3Crect x='2' y='5' width='4' height='2' fill='white' opacity='0.35'/%3E%3Crect x='10' y='4' width='6' height='2' fill='white' opacity='0.25'/%3E%3Crect x='20' y='6' width='3' height='1' fill='white' opacity='0.2'/%3E%3Crect x='5' y='12' width='3' height='2' fill='%23f97316' opacity='0.7'/%3E%3Crect x='4' y='13' width='1' height='1' fill='%23fdba74' opacity='0.5'/%3E%3Crect x='18' y='16' width='2' height='1' fill='%232563eb' opacity='0.5'/%3E%3Crect x='20' y='16' width='1' height='1' fill='%2393c5fd' opacity='0.4'/%3E%3Crect x='8' y='18' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='22' y='12' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='14' y='20' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='3' y='23' width='4' height='3' fill='%23059669' opacity='0.5'/%3E%3Crect x='4' y='22' width='2' height='1' fill='%2310b981' opacity='0.4'/%3E%3Crect x='20' y='24' width='3' height='2' fill='%23059669' opacity='0.4'/%3E%3Crect x='12' y='25' width='3' height='2' fill='%23ec4899' opacity='0.45'/%3E%3Crect x='13' y='24' width='2' height='1' fill='%23f472b6' opacity='0.35'/%3E%3C/svg%3E") !important;
border: none !important;
border-radius: 0 !important;
width: 28px !important;
height: 28px !important;
color: white !important;
image-rendering: pixelated;
box-shadow: none !important;
transition: color 0.15s ease !important;
}
.content :deep(.send-btn:hover:not(:disabled)) {
color: #fffde0 !important;
filter: none !important;
box-shadow: 0 0 12px rgba(135, 206, 235, 0.5), 0 0 4px rgba(255, 253, 224, 0.3) !important;
}
.content :deep(.send-btn:disabled) {
opacity: 0.25 !important;
filter: saturate(0.3) !important;
}
/* Selection bar */
.content :deep(.selection-bar) {
background: rgba(8, 8, 12, 0.92);
border-color: rgba(255,255,255,0.06);
border-radius: 0;
}
.content :deep(.selection-count) {
color: rgba(255,255,255,0.4);
font-family: 'Courier New', monospace;
}
.content :deep(.selection-btn.toggle-all) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
border-radius: 0;
}
.content :deep(.message-wrapper.selected) {
background: rgba(99, 102, 241, 0.06);
}
/* ── Empty State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
color: rgba(255,255,255,0.3);
position: relative;
z-index: 1;
}
.empty-state svg {
opacity: 0.2;
}
.empty-state span {
font-size: 11px;
font-family: 'Courier New', monospace;
color: rgba(255,255,255,0.35);
letter-spacing: 0.5px;
}
.empty-state small {
font-size: 10px;
font-family: 'Courier New', monospace;
color: rgba(255,255,255,0.2);
}
/* ── Resize Handle ── */
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Crect x='12' y='12' width='2' height='2' fill='%23818cf8' opacity='0.25'/%3E%3Crect x='8' y='12' width='2' height='2' fill='%23818cf8' opacity='0.15'/%3E%3Crect x='12' y='8' width='2' height='2' fill='%23818cf8' opacity='0.15'/%3E%3Crect x='4' y='12' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3Crect x='12' y='4' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3Crect x='8' y='8' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3C/svg%3E") no-repeat center center;
border-radius: 0 0 10px 0;
}
.resize-handle:hover {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Crect x='12' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.4'/%3E%3Crect x='8' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.25'/%3E%3Crect x='12' y='8' width='2' height='2' fill='%23a5b4fc' opacity='0.25'/%3E%3Crect x='4' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3Crect x='12' y='4' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3Crect x='8' y='8' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3C/svg%3E") no-repeat center center;
}
.aero-win.resizing {
user-select: none;
}
.aero-win.resizing .content {
pointer-events: none;
}
/* ── Transitions ── */
.win-slide-enter-active,
.win-slide-leave-active {
transition: all .2s ease;
}
.win-slide-enter-from,
.win-slide-leave-to {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
/* ══════════════════════════════════════════════════════════════════════════
OCEAN LIFE ECOSYSTEM
══════════════════════════════════════════════════════════════════════════ */
.ocean-life {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.cr {
position: absolute;
width: 1px;
height: 1px;
background: transparent;
font-style: normal;
}
/* ── 1. Fish School (3 orange fish, swim left) ── */
.fish-school {
top: 16px;
animation: swim-left 10s linear infinite;
box-shadow:
0 0 #f97316, 1px 0 #f97316, -1px 0 #fb923c,
5px 3px #f97316, 6px 3px #f97316, 4px 3px #fb923c,
3px -2px #f97316, 4px -2px #f97316, 2px -2px #fb923c;
}
/* ── 2. Fish School 2 (2 blue fish, swim right) ── */
.fish-school-2 {
top: 20px;
animation: swim-right 14s linear infinite;
box-shadow:
0 0 #818cf8, -1px 0 #818cf8, 1px 0 #a5b4fc,
-4px 2px #818cf8, -5px 2px #818cf8, -3px 2px #a5b4fc;
}
/* ── 3. Dolphin (grey-blue, arc jump) ── */
.dolphin {
top: 14px;
animation: dolphin-arc 12s linear infinite;
box-shadow:
0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #94a3b8, 3px 0 #cbd5e1, -1px 0 #64748b,
0 1px #64748b, 1px 1px #64748b, 2px 1px #94a3b8,
-1px 2px #94a3b8;
}
/* ── 4. Jellyfish (purple, bob) ── */
.jellyfish {
top: 18px;
animation: swim-left 25s linear infinite, jelly-bob 3s ease-in-out infinite;
box-shadow:
0 -1px #c084fc,
-1px 0 #a855f7, 0 0 #a855f7, 1px 0 #a855f7,
0 1px #c084fc,
-1px 2px #7c3aed, 1px 2px #7c3aed,
0 3px #7c3aed;
}
/* ── 5. Seahorse (yellow-orange, slow drift) ── */
.seahorse {
top: 15px;
animation: swim-left 35s linear infinite, jelly-bob 4s ease-in-out infinite;
box-shadow:
0 -1px #fbbf24,
0 0 #f59e0b, 1px 0 #f59e0b,
0 1px #d97706,
0 2px #d97706,
1px 3px #b45309;
}
/* ── 6. Turtle (green, slow) ── */
.turtle {
top: 14px;
animation: swim-left 50s linear infinite;
box-shadow:
2px -1px #4ade80,
-1px 0 #16a34a, 0 0 #16a34a, 1px 0 #22c55e, 2px 0 #22c55e, 3px 0 #16a34a,
-1px 1px #4ade80, 3px 1px #4ade80;
}
/* ── 7. Pirate Ship (dark silhouette, very slow) ── */
.pirate-ship {
top: 7px;
animation: swim-right 180s linear infinite;
box-shadow:
3px -3px #334155, 3px -2px #334155, 3px -1px #475569,
1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #1e293b, 4px 0 #1e293b, 5px 0 #1e293b,
0 1px #1e293b, 1px 1px #1e293b, 2px 1px #1e293b, 3px 1px #1e293b, 4px 1px #1e293b, 5px 1px #1e293b, 6px 1px #1e293b,
1px 2px #0f172a, 2px 2px #0f172a, 3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a;
}
/* ── 8-10. Bubbles (rising white dots) ── */
.bubble {
animation: bubble-rise 6s linear infinite;
box-shadow: 0 0 rgba(255,255,255,0.45);
}
.bubble.b1 { left: 25%; animation-duration: 6s; }
.bubble.b2 { left: 55%; animation-duration: 9s; }
.bubble.b3 { left: 80%; animation-duration: 11s; }
/* ── 11. Satellite (night, metallic + blue panels) ── */
.satellite {
top: 3px;
animation: swim-left 15s linear infinite;
box-shadow:
-2px 0 #3b82f6, -1px 0 #3b82f6, 0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #3b82f6, 3px 0 #3b82f6;
}
/* ── 12. Anglerfish (night, dark body + glowing lure) ── */
.anglerfish {
top: 22px;
animation: swim-left 40s linear infinite;
box-shadow:
-2px -1px #fbbf24, -2px -2px rgba(251,191,36,0.4),
0 0 #1e293b, 1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #334155,
0 1px #0f172a, 1px 1px #0f172a, 2px 1px #1e293b;
}
/* ── 13-15. Bioluminescence (night, pulsing cyan dots) ── */
.biolum {
box-shadow: 0 0 #22d3ee;
animation: bio-pulse 3.5s ease-in-out infinite;
}
.biolum.bl1 { top: 19px; left: 20%; animation-duration: 3.5s; }
.biolum.bl2 { top: 23px; left: 50%; animation-duration: 4.5s; }
.biolum.bl3 { top: 17px; left: 75%; animation-duration: 5.5s; }
/* ── 16. Comet (rare, fast diagonal streak) ── */
.comet {
top: 2px;
right: 0;
animation: comet-streak 2.5s linear forwards;
box-shadow:
0 0 #fef3c7, -1px 0 #fef3c7,
-2px 1px #fde68a, -3px 1px #fde68a,
-4px 2px rgba(251,191,36,0.6),
-5px 3px rgba(251,191,36,0.3),
-6px 4px rgba(251,191,36,0.15);
}
/* ── 17. Whale (rare, large blue silhouette) ── */
.whale {
top: 14px;
animation: swim-left 90s linear infinite;
box-shadow:
4px -1px #2563eb, 5px -1px #2563eb,
1px 0 #1d4ed8, 2px 0 #1d4ed8, 3px 0 #1d4ed8, 4px 0 #2563eb, 5px 0 #2563eb, 6px 0 #2563eb, 7px 0 #1d4ed8,
0 1px #1e40af, 1px 1px #1e40af, 2px 1px #1d4ed8, 3px 1px #1d4ed8, 4px 1px #1d4ed8, 5px 1px #1d4ed8, 6px 1px #1d4ed8, 7px 1px #1e40af, 8px 1px #1e40af,
2px 2px #1e40af, 3px 2px #1e40af, 4px 2px #1e40af, 5px 2px #1e40af, 6px 2px #1e40af,
7px 3px #1e40af;
}
/* ── 18. Bottle (rare, message in a bottle) ── */
.bottle {
top: 11px;
animation: swim-left 120s linear infinite, jelly-bob 5s ease-in-out infinite;
box-shadow:
0 -1px #d97706,
-1px 0 #92400e, 0 0 #92400e, 1px 0 #fef3c7,
-1px 1px #92400e, 0 1px #92400e;
}
/* ── 19. Leviathan (rare, enormous faint shadow) ── */
.leviathan {
top: 20px;
animation: swim-left 300s linear infinite;
opacity: 0.15;
box-shadow:
2px -1px #1e293b, 3px -1px #1e293b, 4px -1px #1e293b, 5px -1px #1e293b, 6px -1px #1e293b, 7px -1px #1e293b, 8px -1px #1e293b, 9px -1px #1e293b,
0 0 #0f172a, 1px 0 #0f172a, 2px 0 #0f172a, 3px 0 #0f172a, 4px 0 #0f172a, 5px 0 #0f172a, 6px 0 #0f172a, 7px 0 #0f172a, 8px 0 #0f172a, 9px 0 #0f172a, 10px 0 #0f172a, 11px 0 #0f172a, 12px 0 #0f172a,
1px 1px #0f172a, 2px 1px #0f172a, 3px 1px #0f172a, 4px 1px #0f172a, 5px 1px #0f172a, 6px 1px #0f172a, 7px 1px #0f172a, 8px 1px #0f172a, 9px 1px #0f172a, 10px 1px #0f172a, 11px 1px #0f172a,
3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a, 6px 2px #0f172a, 7px 2px #0f172a, 8px 2px #0f172a, 9px 2px #0f172a;
}
/* ── 20. Sun Ray (noon, golden beam) ── */
.sun-ray {
top: 0;
left: 40%;
width: 20px;
height: 100%;
background: linear-gradient(180deg, rgba(251,191,36,0.12) 0%, rgba(251,191,36,0.04) 40%, transparent 100%);
animation: sun-ray-pulse 5s ease-in-out infinite;
}
/* ── 21. Dawn Glow (dawn, warm orange) ── */
.dawn-glow {
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(251,146,60,0.1) 0%, transparent 40%);
animation: sun-ray-pulse 8s ease-in-out infinite;
}
/* ── 22. Party Fish (Fridays, rainbow nyan) ── */
.party-fish {
top: 15px;
animation: swim-right 8s linear infinite;
box-shadow:
-4px 0 #ef4444, -3px 0 #f97316, -2px 0 #eab308, -1px 0 #22c55e, 0 0 #3b82f6, 1px 0 #8b5cf6,
-3px 1px #f97316, -2px 1px #eab308, -1px 1px #22c55e, 0 1px #3b82f6;
}
/* ── 23. Ghost Ship (midnight, ethereal) ── */
.ghost-ship {
top: 7px;
animation: swim-left 240s linear infinite;
opacity: 0.3;
box-shadow:
3px -3px rgba(255,255,255,0.6), 3px -2px rgba(255,255,255,0.5), 3px -1px rgba(255,255,255,0.4),
1px 0 rgba(255,255,255,0.3), 2px 0 rgba(255,255,255,0.3), 3px 0 rgba(255,255,255,0.3), 4px 0 rgba(255,255,255,0.3), 5px 0 rgba(255,255,255,0.3),
0 1px rgba(255,255,255,0.2), 1px 1px rgba(255,255,255,0.2), 2px 1px rgba(255,255,255,0.2), 3px 1px rgba(255,255,255,0.2), 4px 1px rgba(255,255,255,0.2), 5px 1px rgba(255,255,255,0.2), 6px 1px rgba(255,255,255,0.2),
1px 2px rgba(255,255,255,0.15), 2px 2px rgba(255,255,255,0.15), 3px 2px rgba(255,255,255,0.15), 4px 2px rgba(255,255,255,0.15), 5px 2px rgba(255,255,255,0.15);
}
/* ── Ocean Life Keyframes ── */
@keyframes swim-left {
from { left: calc(100% + 15px); }
to { left: -15px; }
}
@keyframes swim-right {
from { left: -15px; }
to { left: calc(100% + 15px); }
}
@keyframes dolphin-arc {
0% { left: calc(100% + 15px); top: 14px; }
35% { top: 14px; }
50% { top: 4px; }
65% { top: 14px; }
100% { left: -15px; top: 14px; }
}
@keyframes jelly-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes bubble-rise {
0% { top: 28px; opacity: 0.5; }
80% { opacity: 0.3; }
100% { top: -2px; opacity: 0; }
}
@keyframes bio-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.5; }
}
@keyframes comet-streak {
0% { top: 1px; left: 100%; opacity: 0.9; }
100% { top: 18px; left: -20px; opacity: 0; }
}
@keyframes sun-ray-pulse {
0%, 100% { opacity: 0.05; }
50% { opacity: 0.18; }
}
@keyframes titlebar-ocean {
from { background-position: 0 0, 0 0; }
to { background-position: 120px 0, 0 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Mobile ── */
.aero-win.mobile {
min-width: unset;
min-height: unset;
max-width: 100%;
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1),
bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.aero-win.mobile .glass {
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.aero-win.mobile .resize-handle {
display: none;
}
.aero-win.mobile .content {
flex: 1;
min-height: 100px;
touch-action: pan-y;
overflow: hidden;
}
.aero-win.mobile.win-slide-enter-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.aero-win.mobile.win-slide-leave-active {
transition: transform 0.25s cubic-bezier(0.4, 0, 1, 1) !important;
}
.aero-win.mobile.win-slide-enter-from,
.aero-win.mobile.win-slide-leave-to {
transform: translateY(100%) !important;
opacity: 1 !important;
}
</style>