Files
agent-ui/frontend/src/components/FloatingTranscriptDebug.vue
josedario87 ca315cf040 feat: AgentBadge component, force mobile mode, and transcript UX improvements
- Extract agent badge into AgentBadge component with dropdown (TODO placeholder)
- Realtime connection indicated by badge color (green=connected, indigo=disconnected)
- Remove Transcript label and chat bubble icon from titlebar
- Add force mobile mode button (bottom sheet panel on desktop)
- Size toggle persisted in localStorage and controls sheet height in mobile mode
- Replace full titlebar drag with thin 5px top edge grip
- Remove sheet-handle touch bar, size controlled via toggle button only
- Large mobile mode respects app header height
- Slide-up/down animation for mobile panel enter/exit
2026-02-19 17:39:01 -06:00

1184 lines
42 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug'
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,
init,
switchAgent,
selectSession,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
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
)
// ============================================================================
// 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')
// 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)
// ============================================================================
// 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 style: Record<string, string> = {
position: 'fixed',
left: '0',
right: '0',
bottom: '0',
width: '100%',
}
if (isFullScreen) {
// Full height but respect app header
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const headerH = headerEl ? headerEl.offsetHeight : 40
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) {
sendPrompt(message)
}
// ============================================================================
// 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(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleZoomKey)
})
onBeforeUnmount(() => {
disconnectRealtime()
document.removeEventListener('keydown', handleZoomKey)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile)
})
</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">
<div class="left">
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" />
</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" />
<ChatContainer
ref="chatRef"
v-if="conversation"
:conversation="conversation"
:processing="processing"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
:sessions="sessions"
:selected-session-id="selectedSessionId"
:sessions-loading="loading"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
/>
<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;
}
.left {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255,255,255,0.6);
font: 500 10px/1 'Courier New', monospace;
}
.window-controls {
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;
background: rgba(0, 0, 0, 0.55);
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);
}
@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);
}
.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>