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.
This commit is contained in:
2026-02-20 12:12:53 -06:00
parent b7f03a777b
commit 220d595568
19 changed files with 2221 additions and 358 deletions

View File

@@ -5,6 +5,7 @@ import Toolbar from './components/Toolbar.vue'
import TorchButton from './components/TorchButton.vue' import TorchButton from './components/TorchButton.vue'
import FloatingTerminal from './components/FloatingTerminal.vue' import FloatingTerminal from './components/FloatingTerminal.vue'
import FloatingResponse from './components/FloatingResponse.vue' import FloatingResponse from './components/FloatingResponse.vue'
import { initWhisperSocket } from './services/whisperSocket'
import FloatingVoice from './components/FloatingVoice.vue' import FloatingVoice from './components/FloatingVoice.vue'
import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue' import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
import AgentBar from './components/AgentBar.vue' import AgentBar from './components/AgentBar.vue'
@@ -70,6 +71,8 @@ function clearDebugLogs() {
const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null) const terminalRef = ref<InstanceType<typeof FloatingTerminal> | null>(null)
const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null) const responseRef = ref<InstanceType<typeof FloatingResponse> | null>(null)
const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null) const voiceRef = ref<InstanceType<typeof FloatingVoice> | null>(null)
const transcriptDebugRef = ref<InstanceType<typeof FloatingTranscriptDebug> | null>(null)
const mousePos = ref({ x: 0, y: 0 })
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore() const projectCanvasStore = useProjectCanvasStore()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval() const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
@@ -104,6 +107,21 @@ function hardRefresh() {
location.reload() location.reload()
} }
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function handleGlobalKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
if (transcriptDebugRef.value) {
transcriptDebugRef.value.openAtCursor(mousePos.value.x, mousePos.value.y)
} else {
showTranscriptDebug.value = !showTranscriptDebug.value
}
}
}
// Voice FAB push-to-talk handlers // Voice FAB push-to-talk handlers
function handleVoiceFabClick() { function handleVoiceFabClick() {
// If touch just ended, ignore click // If touch just ended, ignore click
@@ -273,6 +291,9 @@ onMounted(async () => {
connectApproval() connectApproval()
fetchApprovalPending() fetchApprovalPending()
// Initialize Whisper WebSocket connection early
initWhisperSocket()
// Fire torch connection early (don't await yet) // Fire torch connection early (don't await yet)
const torchReady = initTorch() const torchReady = initTorch()
@@ -342,6 +363,12 @@ onMounted(async () => {
} }
}) })
// Track mouse for Ctrl+E cursor-based opening
document.addEventListener('mousemove', trackMouse)
// Global keyboard shortcut: Ctrl+E toggles Transcript Debug
document.addEventListener('keydown', handleGlobalKeydown)
// Detect virtual keyboard on mobile // Detect virtual keyboard on mobile
if (window.visualViewport) { if (window.visualViewport) {
const initialHeight = window.visualViewport.height const initialHeight = window.visualViewport.height
@@ -357,6 +384,8 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleGlobalKeydown)
destroyTorch() destroyTorch()
disconnectApproval() disconnectApproval()
if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout) if (statusReconnectTimeout) clearTimeout(statusReconnectTimeout)
@@ -577,7 +606,7 @@ watch(() => route.name, (newPage) => {
<FloatingVoice ref="voiceRef" v-model="showVoice" /> <FloatingVoice ref="voiceRef" v-model="showVoice" />
<!-- Floating Transcript Debug --> <!-- Floating Transcript Debug -->
<FloatingTranscriptDebug v-model="showTranscriptDebug" /> <FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
<!-- Global Hooks Approval Modal --> <!-- Global Hooks Approval Modal -->
<HooksApprovalModal /> <HooksApprovalModal />

View File

@@ -85,13 +85,6 @@ const renderer = useTerminalRenderer({
} }
}, },
onKeyEvent: (e) => { onKeyEvent: (e) => {
// Ctrl+E: Toggle terminal
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
return false
}
// Ctrl+V: Paste from clipboard // Ctrl+V: Paste from clipboard
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') { if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
e.preventDefault() e.preventDefault()
@@ -216,11 +209,8 @@ function toggleTerminal() {
} }
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(_e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') { // Reserved for future terminal shortcuts
e.preventDefault()
toggleTerminal()
}
} }
function startDrag(e: MouseEvent | TouchEvent) { function startDrag(e: MouseEvent | TouchEvent) {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug' import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug' import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug' import type { AgentName } from '@/types/transcript-debug'
@@ -32,14 +33,31 @@ const {
processing, processing,
ephemeral, ephemeral,
terminalReady, terminalReady,
openTerminals,
activeTerminalSessionId,
init, init,
switchAgent, switchAgent,
selectSession, selectSession,
createNewSession, createNewSession,
switchToTerminal,
closeTerminal,
disconnectRealtime, disconnectRealtime,
sendPrompt sendPrompt
} = useTranscriptDebug() } = 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 }[] = [ const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' }, { id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' }, { id: 'nucleo000', label: 'nucleo000' },
@@ -62,6 +80,46 @@ const showChrome = computed(() =>
isResizing.value || showSelector.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 // DRAG STATE
// ============================================================================ // ============================================================================
@@ -84,6 +142,24 @@ type SizeMode = 'pin' | 'medium' | 'large'
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium') 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 // Force mobile (bottom sheet) mode on desktop
const forceMobile = ref(false) const forceMobile = ref(false)
const effectiveMobile = computed(() => isMobile.value || forceMobile.value) const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
@@ -117,6 +193,18 @@ function cycleSizeMode() {
const isMobile = ref(false) const isMobile = ref(false)
const sheetHeight = ref(55) 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 // MOBILE DETECTION
// ============================================================================ // ============================================================================
@@ -213,21 +301,34 @@ const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
const windowStyle = computed((): Record<string, string> => { const windowStyle = computed((): Record<string, string> => {
if (effectiveMobile.value) { if (effectiveMobile.value) {
const isFullScreen = sheetHeight.value >= 100 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> = { const style: Record<string, string> = {
position: 'fixed', position: 'fixed',
left: '0', left: '0',
right: '0', right: '0',
bottom: '0',
width: '100%', width: '100%',
} }
if (isFullScreen) {
// Full height but respect app header if (keyboardVisible.value) {
const headerEl = document.querySelector('.app-header') as HTMLElement | null // Body is position:fixed so no browser auto-scroll — simple offset works
const headerH = headerEl ? headerEl.offsetHeight : 40 style.bottom = `${keyboardHeight.value}px`
style.top = `${headerH}px` const available = window.innerHeight - keyboardHeight.value - headerH
style.height = 'auto' 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 { } else {
style.height = `${sheetHeight.value}dvh` // No keyboard
style.bottom = '0'
if (isFullScreen) {
style.top = `${headerH}px`
style.height = 'auto'
} else {
style.height = `${sheetHeight.value}dvh`
}
} }
return style return style
} }
@@ -335,6 +436,7 @@ function handleSessionSelect(sessionId: string) {
} }
function handleSend(message: string) { function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message) sendPrompt(message)
} }
@@ -373,20 +475,29 @@ function handleZoomKey(e: KeyboardEvent) {
// LIFECYCLE // LIFECYCLE
// ============================================================================ // ============================================================================
onMounted(() => { onMounted(async () => {
checkMobile() checkMobile()
window.addEventListener('resize', checkMobile) window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleZoomKey) document.addEventListener('keydown', handleZoomKey)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onVisualViewportResize)
}
oceanLifeTimer = setInterval(tickOceanLife, 20000)
tickOceanLife()
await voice.init()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
disconnectRealtime() disconnectRealtime()
voice.cleanup()
document.removeEventListener('keydown', handleZoomKey) document.removeEventListener('keydown', handleZoomKey)
document.removeEventListener('mousemove', onDrag) document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag) document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize) document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize) document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile) window.removeEventListener('resize', checkMobile)
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
}) })
</script> </script>
@@ -420,8 +531,49 @@ onBeforeUnmount(() => {
<!-- Titlebar --> <!-- Titlebar -->
<div class="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"> <div class="left">
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" /> <AgentBadge
v-if="selectedAgent"
:agent="selectedAgent"
:connected="isRealtime"
:terminals="openTerminals"
:active-session-id="activeTerminalSessionId"
@switch-terminal="switchToTerminal"
@close-terminal="closeTerminal"
/>
</div> </div>
<div class="window-controls"> <div class="window-controls">
<button <button
@@ -486,7 +638,7 @@ onBeforeUnmount(() => {
<!-- Content --> <!-- Content -->
<div class="content"> <div class="content">
<AquaticBackground /> <AquaticBackground />
<div class="readability-overlay" /> <div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<ChatContainer <ChatContainer
ref="chatRef" ref="chatRef"
v-if="conversation" v-if="conversation"
@@ -500,10 +652,27 @@ onBeforeUnmount(() => {
:sessions="sessions" :sessions="sessions"
:selected-session-id="selectedSessionId" :selected-session-id="selectedSessionId"
:sessions-loading="loading" :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" @send="handleSend"
@switch-agent="handleAgentSwitch" @switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect" @select-session="handleSessionSelect"
@create-session="handleCreateSession" @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"> <div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -733,9 +902,12 @@ onBeforeUnmount(() => {
border-bottom: 1px solid rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.04);
user-select: none; user-select: none;
transition: opacity 0.35s ease, transform 0.35s ease; transition: opacity 0.35s ease, transform 0.35s ease;
animation: titlebar-ocean 8s linear infinite;
} }
.left { .left {
position: relative;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -744,6 +916,8 @@ onBeforeUnmount(() => {
} }
.window-controls { .window-controls {
position: relative;
z-index: 1;
display: flex; display: flex;
gap: 2px; gap: 2px;
} }
@@ -831,7 +1005,6 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 0; z-index: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
transition: opacity 0.35s ease; transition: opacity 0.35s ease;
@@ -1148,6 +1321,275 @@ onBeforeUnmount(() => {
} }
/* ══════════════════════════════════════════════════════════════════════════
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 { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@@ -1157,7 +1599,8 @@ onBeforeUnmount(() => {
min-width: unset; min-width: unset;
min-height: unset; min-height: unset;
max-width: 100%; max-width: 100%;
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1); 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 { .aero-win.mobile .glass {

View File

@@ -1,9 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import type { TerminalSlot } from '@/types/transcript-debug'
defineProps<{ defineProps<{
agent: string agent: string
connected: boolean connected: boolean
terminals: TerminalSlot[]
activeSessionId: string | null
}>()
const emit = defineEmits<{
'switch-terminal': [sessionId: string]
'close-terminal': [sessionId: string]
}>() }>()
const isOpen = ref(false) const isOpen = ref(false)
@@ -21,6 +29,22 @@ function onClickOutside(e: MouseEvent) {
} }
} }
function handleSwitch(sessionId: string) {
emit('switch-terminal', sessionId)
isOpen.value = false
}
function handleClose(e: Event, sessionId: string) {
e.stopPropagation()
emit('close-terminal', sessionId)
}
function slotColor(t: TerminalSlot): string {
if (!t.alive) return '#f87171' // PTY dead → red
if (t.clients > 0) return '#4ade80' // alive + connected → green
return '#fbbf24' // alive, no clients (parked) → orange
}
onMounted(() => document.addEventListener('mousedown', onClickOutside)) onMounted(() => document.addEventListener('mousedown', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside)) onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</script> </script>
@@ -28,6 +52,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
<template> <template>
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle"> <div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
<span class="agent-label">{{ agent }}</span> <span class="agent-label">{{ agent }}</span>
<span v-if="terminals.length > 1" class="term-count">{{ terminals.length }}</span>
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges"> <svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
<rect x="2" y="4" width="2" height="2" fill="currentColor"/> <rect x="2" y="4" width="2" height="2" fill="currentColor"/>
<rect x="0" y="2" width="2" height="2" fill="currentColor"/> <rect x="0" y="2" width="2" height="2" fill="currentColor"/>
@@ -35,7 +60,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
</svg> </svg>
<Transition name="dropdown"> <Transition name="dropdown">
<div v-if="isOpen" class="dropdown"> <div v-if="isOpen" class="dropdown">
<div class="dropdown-item todo">TODO</div> <div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
<div
v-for="t in terminals"
:key="t.sessionId"
class="dropdown-item terminal-item"
:class="{ active: t.sessionId === activeSessionId }"
@click.stop="handleSwitch(t.sessionId)"
>
<span class="state-dot" :style="{ background: slotColor(t) }" />
<span class="terminal-label">{{ t.label }}</span>
<button class="close-btn" @click="handleClose($event, t.sessionId)" title="Close terminal">
<svg width="6" height="6" viewBox="0 0 6 6">
<line x1="0" y1="0" x2="6" y2="6" stroke="currentColor" stroke-width="1.2"/>
<line x1="6" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.2"/>
</svg>
</button>
</div>
</div> </div>
</Transition> </Transition>
</div> </div>
@@ -84,6 +125,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
color: #86efac; color: #86efac;
} }
.term-count {
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(165, 180, 252, 0.6);
background: rgba(99, 102, 241, 0.2);
padding: 0 3px;
min-width: 12px;
text-align: center;
line-height: 12px;
}
.connected .term-count {
color: rgba(134, 239, 172, 0.6);
background: rgba(34, 197, 94, 0.15);
}
.caret { .caret {
color: rgba(165, 180, 252, 0.5); color: rgba(165, 180, 252, 0.5);
transition: transform 0.2s ease; transition: transform 0.2s ease;
@@ -101,13 +159,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
min-width: 120px; min-width: 180px;
max-width: 260px;
max-height: 280px;
overflow-y: auto;
background: rgba(8, 8, 18, 0.95); background: rgba(8, 8, 18, 0.95);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(99, 102, 241, 0.2); border: 1px solid rgba(99, 102, 241, 0.2);
z-index: 100; z-index: 100;
padding: 4px 0; padding: 3px 0;
} }
.dropdown-item { .dropdown-item {
@@ -116,12 +177,69 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase;
} }
.dropdown-item.todo { .dropdown-item.empty {
color: rgba(255, 255, 255, 0.25); color: rgba(255, 255, 255, 0.25);
font-style: italic; font-style: italic;
text-align: center;
}
.dropdown-item.terminal-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
cursor: pointer;
transition: background 0.1s;
}
.dropdown-item.terminal-item:hover {
background: rgba(99, 102, 241, 0.12);
}
.dropdown-item.terminal-item.active {
background: rgba(99, 102, 241, 0.18);
color: rgba(255, 255, 255, 0.8);
}
.state-dot {
width: 5px;
height: 5px;
flex-shrink: 0;
border-radius: 0;
}
.terminal-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.2);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.dropdown-item.terminal-item:hover .close-btn {
opacity: 1;
}
.close-btn:hover {
color: #fca5a5;
background: rgba(239, 68, 68, 0.2);
} }
/* Transition */ /* Transition */
@@ -138,4 +256,17 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
opacity: 0; opacity: 0;
transform: translateY(-4px); transform: translateY(-4px);
} }
/* Scrollbar */
.dropdown::-webkit-scrollbar {
width: 4px;
}
.dropdown::-webkit-scrollbar-track {
background: transparent;
}
.dropdown::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.2);
}
</style> </style>

View File

@@ -27,6 +27,16 @@ const props = defineProps<{
sessions?: { id: string; firstUserMessage?: string }[] sessions?: { id: string; firstUserMessage?: string }[]
selectedSessionId?: string | null selectedSessionId?: string | null
sessionsLoading?: boolean sessionsLoading?: boolean
voiceMode?: 'web' | 'whisper'
whisperStatus?: 'offline' | 'loading' | 'ready'
audioDevices?: MediaDeviceInfo[]
selectedDeviceId?: string
isRecording?: boolean
voiceTranscript?: string
lastAudioUrl?: string
isPlayingAudio?: boolean
overlayOpacity?: number
inputMaxLines?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,6 +44,13 @@ const emit = defineEmits<{
switchAgent: [agent: AgentName] switchAgent: [agent: AgentName]
selectSession: [sessionId: string] selectSession: [sessionId: string]
createSession: [] createSession: []
startRecording: []
stopRecording: []
setVoiceMode: [mode: 'web' | 'whisper']
selectMicrophone: [deviceId: string]
playLastAudio: []
'update:overlayOpacity': [value: number]
'update:inputMaxLines': [value: number]
}>() }>()
const scrollContainer = ref<HTMLElement | null>(null) const scrollContainer = ref<HTMLElement | null>(null)
@@ -286,6 +303,71 @@ function formatDuration(start: string, end: string): string {
:terminal="terminal ?? null" :terminal="terminal ?? null"
/> />
</div> </div>
<div v-if="voiceMode" class="selector-row">
<label class="selector-label">Mic</label>
<select
class="session-select"
:value="selectedDeviceId || ''"
@change="emit('selectMicrophone', ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>Select mic...</option>
<option v-for="d in audioDevices" :key="d.deviceId" :value="d.deviceId">
{{ d.label || 'Microphone ' + d.deviceId.slice(0, 8) }}
</option>
</select>
<button
class="mode-toggle-btn"
:class="{ gpu: voiceMode === 'whisper' }"
@click="emit('setVoiceMode', voiceMode === 'whisper' ? 'web' : 'whisper')"
:title="voiceMode === 'whisper' ? 'Switch to Web Speech' : 'Switch to Whisper GPU'"
>
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</button>
<span
class="whisper-dot"
:class="whisperStatus"
:title="'Whisper: ' + (whisperStatus || 'offline')"
></span>
<button
v-if="lastAudioUrl"
class="play-audio-btn"
:class="{ playing: isPlayingAudio }"
@click="emit('playLastAudio')"
title="Play last recording"
>
<svg v-if="!isPlayingAudio" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16"/>
<rect x="14" y="4" width="4" height="16"/>
</svg>
</button>
</div>
<div class="selector-row">
<label class="selector-label">Overlay</label>
<input
type="range"
class="overlay-slider"
min="0"
max="100"
:value="Math.round((overlayOpacity ?? 0.55) * 100)"
@input="emit('update:overlayOpacity', +($event.target as HTMLInputElement).value / 100)"
/>
<span class="overlay-value">{{ Math.round((overlayOpacity ?? 0.55) * 100) }}%</span>
</div>
<div class="selector-row">
<label class="selector-label">Lines</label>
<input
type="range"
class="overlay-slider"
min="1"
max="12"
:value="inputMaxLines ?? 6"
@input="emit('update:inputMaxLines', +($event.target as HTMLInputElement).value)"
/>
<span class="overlay-value">{{ inputMaxLines ?? 6 }}</span>
</div>
</div> </div>
<div ref="scrollContainer" class="messages-scroll"> <div ref="scrollContainer" class="messages-scroll">
<template <template
@@ -367,7 +449,14 @@ function formatDuration(start: string, end: string): string {
<UserInput <UserInput
:processing="props.processing" :processing="props.processing"
:terminal-ready="props.terminalReady" :terminal-ready="props.terminalReady"
:voice-transcript="voiceTranscript"
:is-recording="isRecording"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:max-lines="props.inputMaxLines"
@send="emit('send', $event)" @send="emit('send', $event)"
@start-recording="emit('startRecording')"
@stop-recording="emit('stopRecording')"
/> />
<div class="status-bar"> <div class="status-bar">
@@ -792,4 +881,124 @@ function formatDuration(start: string, end: string): string {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Mic settings row ── */
.mode-toggle-btn {
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
background: transparent;
color: var(--text-muted, rgba(255,255,255,0.4));
cursor: pointer;
transition: all 0.15s;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.mode-toggle-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
color: var(--text-secondary, rgba(255,255,255,0.7));
}
.mode-toggle-btn.gpu {
background: rgba(16, 185, 129, 0.12);
border-color: rgba(16, 185, 129, 0.25);
color: #4ade80;
}
.whisper-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.whisper-dot.offline {
background: #6b7280;
}
.whisper-dot.loading {
background: #f59e0b;
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.whisper-dot.ready {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
}
.play-audio-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
background: transparent;
color: var(--text-muted, rgba(255,255,255,0.4));
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.play-audio-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
color: var(--text-primary, rgba(255,255,255,0.8));
}
.play-audio-btn.playing {
color: #f59e0b;
}
/* Overlay slider */
.overlay-slider {
flex: 1;
min-width: 0;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color, rgba(255,255,255,0.08));
border-radius: 0;
outline: none;
cursor: pointer;
}
.overlay-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #0ea5e9;
border: none;
border-radius: 0;
cursor: pointer;
}
.overlay-slider::-moz-range-thumb {
width: 10px;
height: 10px;
background: #0ea5e9;
border: none;
border-radius: 0;
cursor: pointer;
}
.overlay-value {
font-size: 9px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: var(--text-muted, rgba(255,255,255,0.4));
min-width: 28px;
text-align: right;
flex-shrink: 0;
}
</style> </style>

View File

@@ -1,16 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import VoiceMicButton from './VoiceMicButton.vue'
const props = defineProps<{ const props = defineProps<{
processing?: boolean processing?: boolean
terminalReady?: boolean terminalReady?: boolean
voiceTranscript?: string
isRecording?: boolean
voiceMode?: 'web' | 'whisper'
whisperStatus?: 'offline' | 'loading' | 'ready'
maxLines?: number
}>() }>()
const maxH = computed(() => {
const lines = props.maxLines ?? 6
// line-height is 1.5 at 13px = ~20px per line, plus padding
return lines <= 1 ? '20px' : `${lines * 20}px`
})
const emit = defineEmits<{ const emit = defineEmits<{
send: [message: string] send: [message: string]
startRecording: []
stopRecording: []
}>() }>()
const input = ref('') const input = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
function adjustHeight() {
const el = textareaRef.value
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
const notReady = computed(() => props.terminalReady === false) const notReady = computed(() => props.terminalReady === false)
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value) const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
@@ -20,6 +42,7 @@ function handleSend() {
if (!msg || props.processing || notReady.value) return if (!msg || props.processing || notReady.value) return
emit('send', msg) emit('send', msg)
input.value = '' input.value = ''
nextTick(adjustHeight)
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
@@ -28,6 +51,16 @@ function handleKeydown(e: KeyboardEvent) {
handleSend() handleSend()
} }
} }
// Fill textarea with voice transcript
watch(() => props.voiceTranscript, (newText) => {
if (newText && newText.trim()) {
input.value = newText
}
})
// Auto-grow textarea when content changes
watch(input, () => nextTick(adjustHeight))
</script> </script>
<template> <template>
@@ -46,13 +79,24 @@ function handleKeydown(e: KeyboardEvent) {
</div> </div>
<div class="input-container" :class="{ disabled: processing || notReady }"> <div class="input-container" :class="{ disabled: processing || notReady }">
<textarea <textarea
ref="textareaRef"
v-model="input" v-model="input"
class="input-field" class="input-field"
:style="{ maxHeight: maxH }"
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'" :placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
rows="1" rows="1"
:disabled="processing || notReady" :disabled="processing || notReady"
@keydown="handleKeydown" @keydown="handleKeydown"
/> />
<VoiceMicButton
v-if="voiceMode"
:is-recording="isRecording ?? false"
:voice-mode="voiceMode"
:whisper-status="whisperStatus ?? 'offline'"
:disabled="processing || notReady"
@start="emit('startRecording')"
@stop="emit('stopRecording')"
/>
<button <button
class="send-btn" class="send-btn"
:disabled="isDisabled" :disabled="isDisabled"
@@ -139,7 +183,7 @@ function handleKeydown(e: KeyboardEvent) {
line-height: 1.5; line-height: 1.5;
resize: none; resize: none;
min-height: 20px; min-height: 20px;
max-height: 120px; overflow-y: auto;
padding: 0.15rem 0.25rem; padding: 0.15rem 0.25rem;
font-family: inherit; font-family: inherit;
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { ParsedUserMessage } from '@/types/transcript-debug' import type { ParsedUserMessage } from '@/types/transcript-debug'
import MarkdownContent from './MarkdownContent.vue'
const props = defineProps<{ const props = defineProps<{
message: ParsedUserMessage message: ParsedUserMessage
@@ -14,6 +15,57 @@ const emit = defineEmits<{
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-')) const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
// ── Command / special message detection ──
type CommandInfo =
| { type: 'caveat'; text: string }
| { type: 'command'; name: string; message: string; args: string }
| { type: 'stdout'; text: string }
| { type: 'interrupted' }
| { type: 'meta-action'; text: string }
| null
const commandInfo = computed<CommandInfo>(() => {
const c = props.message.content
if (!c) return null
// [Request interrupted by user ...]
if (c.includes('[Request interrupted by user')) {
return { type: 'interrupted' }
}
// Meta messages: "Continue from where you left off", etc.
if (props.message.isMeta) {
return { type: 'meta-action', text: c.trim() }
}
// <local-command-caveat>...</local-command-caveat>
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
// <command-name>...</command-name>
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
if (cmdNameMatch) {
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
return {
type: 'command',
name: cmdNameMatch[1].trim(),
message: msgMatch?.[1]?.trim() || '',
args: argsMatch?.[1]?.trim() || ''
}
}
// <local-command-stdout>...</local-command-stdout>
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
return { type: 'stdout', text: stdoutMatch[1].trim() }
}
return null
})
const isCommand = computed(() => commandInfo.value !== null)
function formatTime(ts: string): string { function formatTime(ts: string): string {
if (!ts) return '' if (!ts) return ''
return new Date(ts).toLocaleTimeString() return new Date(ts).toLocaleTimeString()
@@ -21,7 +73,65 @@ function formatTime(ts: string): string {
</script> </script>
<template> <template>
<div :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]"> <!-- COMMAND MESSAGE (compact) -->
<div v-if="isCommand" class="cmd-row">
<!-- Caveat: system note about local commands -->
<template v-if="commandInfo!.type === 'caveat'">
<span class="cmd-icon cmd-icon-caveat">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
</span>
<span class="cmd-label cmd-caveat-text">local command caveat</span>
</template>
<!-- Command invocation -->
<template v-else-if="commandInfo!.type === 'command'">
<span class="cmd-icon">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg>
</span>
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
</template>
<!-- Stdout -->
<template v-else-if="commandInfo!.type === 'stdout'">
<span class="cmd-icon cmd-icon-out">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
</svg>
</span>
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
<span v-else class="cmd-empty">no output</span>
</template>
<!-- Interrupted -->
<template v-else-if="commandInfo!.type === 'interrupted'">
<span class="cmd-icon cmd-icon-interrupted">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
</svg>
</span>
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
</template>
<!-- Meta action (continue, etc.) -->
<template v-else-if="commandInfo!.type === 'meta-action'">
<span class="cmd-icon cmd-icon-meta">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</span>
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
</template>
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
</div>
<!-- NORMAL USER MESSAGE -->
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
<div class="divider-line" /> <div class="divider-line" />
<div class="divider-content"> <div class="divider-content">
<div class="divider-header"> <div class="divider-header">
@@ -46,25 +156,142 @@ function formatTime(ts: string): string {
</button> </button>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span> <span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div> </div>
<div class="divider-text">{{ message.content }}</div> <div class="divider-text">
<MarkdownContent :content="message.content" />
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* ══════════════════════════════════════
Command row — compact inline display
══════════════════════════════════════ */
.cmd-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.5rem;
margin: 0.1rem 0;
font-size: 11px;
color: var(--text-muted);
opacity: 0.55;
transition: opacity 0.15s;
}
.cmd-row:hover {
opacity: 0.85;
}
.cmd-icon {
display: flex;
align-items: center;
color: #64748b;
flex-shrink: 0;
}
.cmd-icon-caveat { color: #f59e0b; }
.cmd-icon-out { color: #64748b; }
.cmd-label {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
}
.cmd-name {
font-size: 11px;
font-weight: 600;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #818cf8;
background: rgba(129, 140, 248, 0.08);
padding: 0 0.3rem;
border-radius: 3px;
}
.cmd-args {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-muted);
}
.cmd-stdout {
font-size: 10px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.cmd-empty {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
opacity: 0.6;
}
.cmd-caveat-text {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-icon-interrupted { color: #ef4444; }
.cmd-interrupted-text {
color: rgba(239, 68, 68, 0.7);
font-weight: 500;
font-style: normal;
}
.cmd-icon-meta { color: #f59e0b; }
.cmd-meta-text {
color: rgba(251, 191, 36, 0.65);
font-weight: 500;
font-style: normal;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-time {
margin-left: auto;
font-size: 9px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
opacity: 0.7;
flex-shrink: 0;
}
/* ══════════════════════════════════════
Normal user message
══════════════════════════════════════ */
.user-divider { .user-divider {
width: 100%; width: 100%;
margin: 0.75rem 0 0.25rem; margin: 0.75rem 0 0.25rem;
} }
.divider-line { .divider-line {
height: 1px; display: none;
background: linear-gradient(90deg, transparent, rgba(129, 140, 248, 0.2) 10%, rgba(129, 140, 248, 0.2) 90%, transparent);
margin-bottom: 0.5rem;
} }
.divider-content { .divider-content {
padding: 0 0.25rem; padding: 0.45rem 0.65rem;
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-left: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(0, 0, 0, 0.12);
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
border-radius: 8px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
} }
.divider-header { .divider-header {
@@ -103,19 +330,34 @@ function formatTime(ts: string): string {
line-height: 1.5; line-height: 1.5;
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 500;
white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.divider-text :deep(.md-content) {
font-size: inherit;
line-height: inherit;
color: inherit;
}
/* Tighten markdown spacing inside user bubbles */
.divider-text :deep(.md-content p) {
margin: 0.15em 0;
}
.divider-text :deep(.md-content pre) {
margin: 0.4em 0;
font-size: 11px;
}
.divider-text :deep(.md-content code) {
font-size: 0.9em;
}
/* Meta messages: dimmed */ /* Meta messages: dimmed */
.user-divider.meta { .user-divider.meta {
opacity: 0.45; opacity: 0.45;
} }
.user-divider.meta .divider-line {
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.15) 10%, rgba(251, 191, 36, 0.15) 90%, transparent);
}
/* Collapse button */ /* Collapse button */
.collapse-btn { .collapse-btn {
display: inline-flex; display: inline-flex;
@@ -155,10 +397,6 @@ function formatTime(ts: string): string {
opacity: 0.6; opacity: 0.6;
} }
.user-divider.optimistic .divider-line {
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1) 10%, rgba(99, 102, 241, 0.1) 90%, transparent);
}
.sending-badge { .sending-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
const props = defineProps<{
isRecording: boolean
voiceMode: 'web' | 'whisper'
whisperStatus: 'offline' | 'loading' | 'ready'
disabled?: boolean
}>()
const emit = defineEmits<{
start: []
stop: []
}>()
// ── Interaction state machine ──
// idle → pointerdown → wait 250ms
// if pointerup < 250ms → "click" → locked (recording until next press+release)
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
// locked → pointerdown → stopping
// stopping → pointerup → stop recording → idle
// ptt → pointerup → wait 500ms trail → stop recording → idle
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
const mode = ref<Mode>('idle')
const HOLD_THRESHOLD = 250
const TRAIL_BUFFER = 500
let holdTimer: number | null = null
let trailTimer: number | null = null
function clearTimers() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
}
function onPointerDown(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
// Capture pointer so we get pointerup even if cursor leaves button
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
if (mode.value === 'idle') {
// Start hold detection
holdTimer = window.setTimeout(() => {
holdTimer = null
// Still holding after 250ms → PTT mode
mode.value = 'ptt'
emit('start')
}, HOLD_THRESHOLD)
} else if (mode.value === 'locked') {
// Second press while locked → prepare to stop on release
mode.value = 'stopping'
}
}
function onPointerUp(e: PointerEvent) {
if (props.disabled) return
e.preventDefault()
if (mode.value === 'idle' && holdTimer) {
// Quick click (< 250ms) → lock mode
clearTimers()
mode.value = 'locked'
emit('start')
} else if (mode.value === 'ptt') {
// Release from PTT → 500ms trailing buffer then stop
clearTimers()
trailTimer = window.setTimeout(() => {
trailTimer = null
mode.value = 'idle'
emit('stop')
}, TRAIL_BUFFER)
} else if (mode.value === 'stopping') {
// Release from second press → stop immediately
clearTimers()
mode.value = 'idle'
emit('stop')
}
}
onBeforeUnmount(() => {
clearTimers()
})
</script>
<template>
<button
class="mic-btn"
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
:disabled="disabled"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
>
<!-- Mic icon -->
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<!-- Stop icon (square) when recording -->
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
<!-- Mode badge -->
<span class="mode-badge" :class="voiceMode">
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
</span>
</button>
</template>
<style scoped>
.mic-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: var(--bg-hover, rgba(255,255,255,0.06));
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
border-radius: 6px;
color: var(--text-muted, #888);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
touch-action: none;
user-select: none;
}
.mic-btn:hover:not(:disabled) {
background: var(--bg-hover, rgba(255,255,255,0.1));
color: var(--text-primary, #ccc);
border-color: var(--text-muted, rgba(255,255,255,0.15));
}
.mic-btn.recording {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: rgba(239, 68, 68, 0.5);
color: white;
animation: rec-pulse 1.5s ease-in-out infinite;
}
.mic-btn.recording.ptt {
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
border-color: rgba(249, 115, 22, 0.5);
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
}
.mic-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.mode-badge {
position: absolute;
bottom: -4px;
right: -4px;
font-size: 7px;
font-weight: 700;
font-family: 'Courier New', monospace;
padding: 1px 3px;
border-radius: 3px;
line-height: 1;
letter-spacing: 0.3px;
pointer-events: none;
}
.mode-badge.whisper {
background: rgba(16, 185, 129, 0.9);
color: white;
}
.mode-badge.web {
background: rgba(59, 130, 246, 0.9);
color: white;
}
@keyframes rec-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
}
@keyframes rec-pulse-ptt {
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
}
</style>

View File

@@ -9,6 +9,7 @@ import {
JellyfishDrift, JellyfishDrift,
EventOverlay, EventOverlay,
EdgeFade, EdgeFade,
PixelLife,
} from './layers' } from './layers'
const { start, stop } = useAquaticEvents() const { start, stop } = useAquaticEvents()
@@ -33,6 +34,7 @@ onBeforeUnmount(() => stop())
<BubbleStream /> <BubbleStream />
<FishSchool /> <FishSchool />
<JellyfishDrift /> <JellyfishDrift />
<PixelLife />
<EventOverlay /> <EventOverlay />
<EdgeFade /> <EdgeFade />
</div> </div>

View File

@@ -0,0 +1,356 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAquaticState } from '../useAquaticState'
const { activeEventModifiers } = useAquaticState()
const pixelSeeds = Array.from({ length: 15 }, () => Math.random())
// All swimming creatures are rare — only stationary ones always visible
const showSeahorse = ref(false)
const showOctopus = ref(false)
const showCrab = ref(false)
const showEel = ref(false)
const showPufferfish = ref(false)
const showShrimp = ref(false)
const showNautilus = ref(false)
const showSwordfish = ref(false)
const showStarfish = ref(false)
const showAnchor = ref(false)
const showSubmarine = ref(false)
const showDiver = ref(false)
let tickTimer: ReturnType<typeof setInterval> | null = null
function tickPixelLife() {
// Common creatures — low chance each tick
showSeahorse.value = Math.random() < 0.06
showOctopus.value = Math.random() < 0.05
showCrab.value = Math.random() < 0.06
showEel.value = Math.random() < 0.04
showPufferfish.value = Math.random() < 0.05
showShrimp.value = Math.random() < 0.06
showNautilus.value = Math.random() < 0.04
showSwordfish.value = Math.random() < 0.03
showStarfish.value = Math.random() < 0.05
// Very rare creatures
showAnchor.value = Math.random() < 0.01
showSubmarine.value = Math.random() < 0.008
showDiver.value = Math.random() < 0.015
}
onMounted(() => {
tickPixelLife()
tickTimer = setInterval(tickPixelLife, 60000)
})
onBeforeUnmount(() => {
if (tickTimer) clearInterval(tickTimer)
})
</script>
<template>
<div class="pixel-life">
<!-- Swimming creatures (all rare) -->
<i v-if="showSeahorse" class="px seahorse" :style="{ animationDelay: `-${pixelSeeds[0] * 160}s` }"></i>
<i v-if="showOctopus" class="px octopus" :style="{ animationDelay: `-${pixelSeeds[1] * 200}s` }"></i>
<i v-if="showCrab" class="px crab" :style="{ animationDelay: `-${pixelSeeds[2] * 120}s` }"></i>
<i v-if="showEel" class="px eel" :style="{ animationDelay: `-${pixelSeeds[3] * 70}s` }"></i>
<i v-if="showPufferfish" class="px pufferfish" :style="{ animationDelay: `-${pixelSeeds[4] * 140}s` }"></i>
<i v-if="showShrimp" class="px shrimp" :style="{ animationDelay: `-${pixelSeeds[5] * 50}s` }"></i>
<i v-if="showNautilus" class="px nautilus" :style="{ animationDelay: `-${pixelSeeds[6] * 240}s` }"></i>
<i v-if="showSwordfish" class="px swordfish" :style="{ animationDelay: `-${pixelSeeds[7] * 25}s` }"></i>
<i v-if="showStarfish" class="px starfish-walk" :style="{ animationDelay: `-${pixelSeeds[8] * 360}s` }"></i>
<!-- Stationary creatures (always visible) -->
<i class="px treasure"></i>
<i class="px sea-anemone"></i>
<i class="px clam"></i>
<!-- Very rare creatures -->
<i v-if="showAnchor" class="px anchor"></i>
<i v-if="showSubmarine" class="px submarine" :style="{ animationDelay: `-${pixelSeeds[11] * 320}s` }"></i>
<i v-if="showDiver" class="px diver" :style="{ animationDelay: `-${pixelSeeds[12] * 180}s` }"></i>
</div>
</template>
<style scoped>
/* ══════════════════════════════════════════════════════════════════════════
Pixel Life — box-shadow pixel art creatures for the main background
══════════════════════════════════════════════════════════════════════════ */
.pixel-life {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 8;
}
.px {
position: absolute;
width: 1px;
height: 1px;
background: transparent;
font-style: normal;
transform: scale(3);
transform-origin: center;
will-change: transform;
}
/* ── Keyframes ── */
@keyframes px-swim-left {
from { left: calc(100% + 20px); }
to { left: -20px; }
}
@keyframes px-swim-right {
from { left: -20px; }
to { left: calc(100% + 20px); }
}
@keyframes px-bob {
0%, 100% { transform: scale(3) translateY(0); }
50% { transform: scale(3) translateY(-4px); }
}
@keyframes px-sine {
0% { transform: scale(3) translateY(0); }
25% { transform: scale(3) translateY(-6px); }
50% { transform: scale(3) translateY(0); }
75% { transform: scale(3) translateY(6px); }
100% { transform: scale(3) translateY(0); }
}
@keyframes px-sink {
from { top: -10px; }
to { top: calc(100% + 10px); }
}
@keyframes px-puff {
0%, 100% { transform: scale(3); }
50% { transform: scale(4.5); }
}
@keyframes px-pulse-glow {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
@keyframes px-tentacle {
0%, 100% { transform: scale(3) translateY(0) skewX(0deg); }
25% { transform: scale(3) translateY(-2px) skewX(2deg); }
75% { transform: scale(3) translateY(2px) skewX(-2deg); }
}
@keyframes px-clam-open {
0%, 100% { transform: scale(3) scaleY(1); }
50% { transform: scale(3) scaleY(0.5); }
}
/* ── 1. Seahorse — yellow-orange, gentle bob, swims left ── */
.seahorse {
top: 40%;
animation:
px-swim-left 160s linear infinite,
px-bob 8s ease-in-out infinite;
box-shadow:
1px -2px #fbbf24, 2px -2px #fbbf24,
0 -1px #f59e0b, 1px -1px #f59e0b, 2px -1px #f59e0b,
0 0 #f59e0b, 1px 0 #d97706,
0 1px #d97706, 1px 1px #d97706,
0 2px #b45309, 1px 2px #b45309,
-1px 3px #b45309, 0 3px #92400e,
-1px 4px #92400e;
}
/* ── 2. Octopus — purple-red, 4 trailing tentacles, swims right ── */
.octopus {
top: 55%;
animation: px-swim-right 200s linear infinite;
box-shadow:
1px -1px #a855f7, 2px -1px #a855f7,
0 0 #9333ea, 1px 0 #9333ea, 2px 0 #9333ea, 3px 0 #a855f7,
0 1px #7c3aed, 1px 1px #7c3aed, 2px 1px #7c3aed, 3px 1px #7c3aed,
-1px 2px #6d28d9, 0 2px #7c3aed, 2px 2px #7c3aed, 3px 2px #6d28d9,
-1px 3px #5b21b6, 1px 3px #6d28d9, 3px 3px #5b21b6,
-2px 4px #4c1d95, 0 4px #5b21b6, 2px 4px #5b21b6, 4px 4px #4c1d95;
}
/* ── 3. Crab — red-orange, walking along bottom ── */
.crab {
top: 85%;
animation: px-swim-right 120s linear infinite;
box-shadow:
-2px -1px #dc2626, 4px -1px #dc2626,
-2px 0 #ef4444, -1px 0 #ef4444, 4px 0 #ef4444, 3px 0 #ef4444,
0 0 #b91c1c, 1px 0 #dc2626, 2px 0 #dc2626,
0 1px #991b1b, 1px 1px #b91c1c, 2px 1px #b91c1c,
-1px 2px #7f1d1d, 0 2px #991b1b, 2px 2px #991b1b, 3px 2px #7f1d1d;
}
/* ── 4. Eel — green-dark, sinuous, swims left ── */
.eel {
top: 45%;
animation:
px-swim-left 70s linear infinite,
px-sine 4s ease-in-out infinite;
box-shadow:
0 0 #16a34a, 1px 0 #15803d, 2px 0 #166534, 3px 0 #14532d,
4px 0 #166534, 5px 0 #15803d, 6px 0 #16a34a, 7px 0 #15803d,
1px 1px #22c55e, 2px 1px #22c55e, 3px 1px #22c55e, 5px 1px #22c55e, 6px 1px #22c55e,
0 -1px #fbbf24;
}
/* ── 5. Pufferfish — yellow, inflates via scale pulse ── */
.pufferfish {
top: 35%;
animation:
px-swim-left 140s linear infinite,
px-puff 10s ease-in-out infinite;
box-shadow:
0 -1px #fbbf24, 1px -1px #fbbf24,
-1px 0 #f59e0b, 0 0 #eab308, 1px 0 #eab308, 2px 0 #f59e0b,
-1px 1px #f59e0b, 0 1px #eab308, 1px 1px #eab308, 2px 1px #f59e0b,
0 2px #d97706, 1px 2px #d97706,
-2px 0 #ca8a04, 3px 0 #ca8a04, 0 -2px #ca8a04, 1px -2px #ca8a04,
0 0 #1e293b;
}
/* ── 6. Shrimp — pink-red, dart near bottom ── */
.shrimp {
top: 80%;
animation: px-swim-right 50s linear infinite;
box-shadow:
0 0 #fb7185, 1px 0 #f43f5e, 2px 0 #e11d48, 3px 0 #be123c,
0 1px #fda4af, 1px 1px #fb7185, 2px 1px #f43f5e,
-1px -1px rgba(251,113,133,0.6), -2px -2px rgba(251,113,133,0.3),
4px 0 #9f1239, 5px -1px #881337;
}
/* ── 7. Nautilus — cream-brown spiral shell, swims left ── */
.nautilus {
top: 50%;
animation:
px-swim-left 240s linear infinite,
px-bob 12s ease-in-out infinite;
box-shadow:
0 -1px #d4a574, 1px -1px #c2956a,
-1px 0 #d4a574, 0 0 #b08050, 1px 0 #a0714a, 2px 0 #c2956a,
-1px 1px #c2956a, 0 1px #8b6040, 1px 1px #a0714a, 2px 1px #b08050,
0 2px #c2956a, 1px 2px #d4a574,
-2px 1px #fef3c7, -2px 2px #fde68a;
}
/* ── 8. Swordfish — silver-blue, fast streak ── */
.swordfish {
top: 25%;
animation: px-swim-left 25s linear infinite;
box-shadow:
-3px 0 #94a3b8, -2px 0 #94a3b8,
-1px 0 #64748b, 0 0 #475569, 1px 0 #475569,
2px 0 #334155, 3px 0 #334155, 4px 0 #475569, 5px 0 #64748b,
1px 1px #94a3b8, 2px 1px #94a3b8, 3px 1px #94a3b8, 4px 1px #94a3b8,
2px -1px #334155, 3px -1px #334155,
6px -1px #475569, 6px 1px #475569;
}
/* ── 9. Starfish — orange, very slow crawl along floor ── */
.starfish-walk {
top: 88%;
animation: px-swim-right 360s linear infinite;
box-shadow:
0 0 #f97316, 1px 0 #ea580c,
0 -2px #fb923c, 1px -2px #fb923c,
-2px 0 #fb923c, 3px 0 #fb923c,
-1px 2px #fb923c, 2px 2px #fb923c,
0 -1px #f97316, 1px -1px #f97316,
-1px 0 #f97316, 2px 0 #f97316,
0 1px #ea580c, 1px 1px #ea580c,
-1px 1px #f97316, 2px 1px #f97316;
}
/* ── 10. Anchor — grey iron, sinks slowly (very rare) ── */
.anchor {
left: 70%;
animation: px-sink 100s linear forwards;
box-shadow:
0 -3px #6b7280, 1px -3px #6b7280,
-1px -2px #6b7280, 2px -2px #6b7280,
0 -2px #9ca3af, 1px -2px #9ca3af,
0 -1px #4b5563, 1px -1px #4b5563,
0 0 #4b5563, 1px 0 #4b5563,
0 1px #4b5563, 1px 1px #4b5563,
0 2px #374151, 1px 2px #374151,
-2px 2px #6b7280, -1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #6b7280,
-2px 3px #374151, 3px 3px #374151,
-1px 3px #4b5563, 2px 3px #4b5563;
}
/* ── 11. Treasure — brown chest with gold gleam (stationary) ── */
.treasure {
top: 90%;
left: 25%;
animation: px-pulse-glow 10s ease-in-out infinite;
box-shadow:
0 -1px #92400e, 1px -1px #92400e, 2px -1px #92400e, 3px -1px #92400e,
-1px 0 #78350f, 0 0 #78350f, 1px 0 #92400e, 2px 0 #92400e, 3px 0 #78350f, 4px 0 #78350f,
-1px 1px #78350f, 0 1px #78350f, 1px 1px #92400e, 2px 1px #92400e, 3px 1px #78350f, 4px 1px #78350f,
1px 0 #fbbf24, 2px 0 #fbbf24,
1px -1px #fde68a;
}
/* ── 12. Submarine — grey with yellow light (very rare), swims right ── */
.submarine {
top: 30%;
animation: px-swim-right 320s linear infinite;
box-shadow:
3px -2px #6b7280, 3px -1px #6b7280,
0 0 #4b5563, 1px 0 #4b5563, 2px 0 #4b5563, 3px 0 #4b5563, 4px 0 #4b5563, 5px 0 #4b5563,
-1px 1px #374151, 0 1px #374151, 1px 1px #374151, 2px 1px #374151, 3px 1px #374151, 4px 1px #374151, 5px 1px #374151, 6px 1px #374151,
0 2px #4b5563, 1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #4b5563, 4px 2px #4b5563, 5px 2px #4b5563,
1px 1px #fbbf24, 4px 1px #fbbf24,
-2px 0 #9ca3af, -2px 1px #9ca3af, -2px 2px #9ca3af;
}
/* ── 13. Diver — black suit, white mask, bubble trail (very rare) ── */
.diver {
top: 40%;
animation: px-swim-left 180s linear infinite;
box-shadow:
0 -1px #f8fafc, 1px -1px #f8fafc,
0 0 #1e293b, 1px 0 #1e293b,
0 1px #1e293b, 1px 1px #1e293b,
0 2px #0f172a, 1px 2px #0f172a,
-1px 3px #1e293b, 2px 3px #1e293b,
2px 0 #475569, 2px 1px #475569,
-1px -2px rgba(255,255,255,0.5), -2px -3px rgba(255,255,255,0.3), -3px -4px rgba(255,255,255,0.15);
}
/* ── 14. Sea Anemone — pink-purple, tentacle wave (stationary) ── */
.sea-anemone {
top: 92%;
left: 60%;
animation: px-tentacle 12s ease-in-out infinite;
box-shadow:
-1px -2px #e879f9, 0 -2px #d946ef, 1px -2px #c026d3, 2px -2px #e879f9,
-1px -1px #d946ef, 0 -1px #c026d3, 1px -1px #d946ef, 2px -1px #c026d3,
0 0 #a21caf, 1px 0 #a21caf,
0 1px #86198f, 1px 1px #86198f;
}
/* ── 15. Clam — grey shell, pearl gleam inside (stationary) ── */
.clam {
top: 90%;
left: 82%;
animation: px-clam-open 16s ease-in-out infinite;
box-shadow:
0 -1px #9ca3af, 1px -1px #9ca3af, 2px -1px #9ca3af,
-1px 0 #6b7280, 0 0 #6b7280, 1px 0 #6b7280, 2px 0 #6b7280, 3px 0 #6b7280,
-1px 1px #4b5563, 0 1px #4b5563, 1px 1px #4b5563, 2px 1px #4b5563, 3px 1px #4b5563,
0 2px #6b7280, 1px 2px #6b7280, 2px 2px #6b7280,
1px 0 #fef3c7;
}
</style>

View File

@@ -7,3 +7,4 @@ export { default as FishSchool } from './FishSchool.vue'
export { default as JellyfishDrift } from './JellyfishDrift.vue' export { default as JellyfishDrift } from './JellyfishDrift.vue'
export { default as EventOverlay } from './EventOverlay.vue' export { default as EventOverlay } from './EventOverlay.vue'
export { default as EdgeFade } from './EdgeFade.vue' export { default as EdgeFade } from './EdgeFade.vue'
export { default as PixelLife } from './PixelLife.vue'

View File

@@ -14,4 +14,5 @@ export { default as PlanApproval } from './PlanApproval.vue'
export { default as CodeBlock } from './CodeBlock.vue' export { default as CodeBlock } from './CodeBlock.vue'
export { default as AgentBadge } from './AgentBadge.vue' export { default as AgentBadge } from './AgentBadge.vue'
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue' export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
export { default as VoiceMicButton } from './VoiceMicButton.vue'
export { AquaticBackground } from './aquaticBackground' export { AquaticBackground } from './aquaticBackground'

View File

@@ -19,9 +19,23 @@ import type {
ProgressEntry, ProgressEntry,
ContentBlock, ContentBlock,
ToolUseBlock, ToolUseBlock,
ToolResultBlock ToolResultBlock,
TerminalSlot
} from '@/types/transcript-debug' } from '@/types/transcript-debug'
// Server registry entry shape (returned by GET /terminal-registry)
interface ServerRegistryEntry {
ephemeralSessionId: string
transcriptSessionId: string
agent: string
label: string
command: string
createdAt: string
alive: boolean
clients: number
bufferSize: number
}
export function useTranscriptDebug() { export function useTranscriptDebug() {
// ── Persistence ── // ── Persistence ──
const STORAGE_KEY = 'transcript-debug-selection' const STORAGE_KEY = 'transcript-debug-selection'
@@ -59,7 +73,7 @@ export function useTranscriptDebug() {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const isRealtime = ref(false) const isRealtime = ref(false)
// ── Ephemeral terminal ── // ── Terminal registry (server-backed) ──
const AGENT_CMD: Record<AgentName, string> = { const AGENT_CMD: Record<AgentName, string> = {
ejecutor: 'ejecutor', ejecutor: 'ejecutor',
@@ -67,29 +81,251 @@ export function useTranscriptDebug() {
claude: 'claude' claude: 'claude'
} }
const ephemeral = shallowRef<EphemeralTerminal | null>(null) // Server registry data (source of truth for all clients)
const serverRegistry = ref<ServerRegistryEntry[]>([])
// Local terminal instances (this client's active WS connections)
const localTerminals = new Map<string, EphemeralTerminal>()
// Which terminal is currently active for THIS client
const activeTerminalSessionId = ref<string | null>(null)
// Computed: current active terminal
const ephemeral = computed<EphemeralTerminal | null>(() => {
if (!activeTerminalSessionId.value) return null
return localTerminals.get(activeTerminalSessionId.value) ?? null
})
const terminalReady = computed(() => ephemeral.value?.state.value === 'running') const terminalReady = computed(() => ephemeral.value?.state.value === 'running')
async function disposeTerminal() { // Computed: terminal list for AgentBadge dropdown (from server registry)
if (ephemeral.value) { const openTerminals = computed<TerminalSlot[]>(() =>
await ephemeral.value.dispose() serverRegistry.value.map(entry => ({
ephemeral.value = null sessionId: entry.transcriptSessionId,
} ephemeralSessionId: entry.ephemeralSessionId,
} agent: entry.agent as AgentName,
label: entry.label,
command: entry.command,
active: entry.transcriptSessionId === activeTerminalSessionId.value,
alive: entry.alive,
clients: entry.clients
}))
)
const awaitingNewSession = ref(false) const awaitingNewSession = ref(false)
// ── Server registry HTTP helpers ──
async function fetchTerminalRegistry() {
try {
const res = await fetch(terminalApiUrl('/terminal-registry'))
if (!res.ok) return
const data = await res.json()
serverRegistry.value = data.registry ?? []
} catch { /* best effort */ }
}
async function registerTerminalOnServer(
ephemeralSessionId: string,
transcriptSessionId: string,
agent: AgentName,
label: string,
command: string
) {
try {
await fetch(terminalApiUrl('/register-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ephemeralSessionId,
transcriptSessionId,
agent,
label,
command,
createdAt: new Date().toISOString()
})
})
// Server broadcasts change → other clients pick it up
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
async function updateTerminalOnServer(
ephemeralSessionId: string,
updates: { transcriptSessionId?: string; label?: string }
) {
try {
await fetch(terminalApiUrl('/update-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId, ...updates })
})
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
async function unregisterTerminalOnServer(ephemeralSessionId: string) {
try {
await fetch(terminalApiUrl('/unregister-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId })
})
await fetchTerminalRegistry()
} catch { /* best effort */ }
}
// ── Registry polling for multi-client sync ──
let registryPollTimer: ReturnType<typeof setInterval> | null = null
function startRegistryPolling() {
if (registryPollTimer) return
registryPollTimer = setInterval(fetchTerminalRegistry, 5000)
}
function stopRegistryPolling() {
if (registryPollTimer) {
clearInterval(registryPollTimer)
registryPollTimer = null
}
}
// ── Terminal lifecycle ──
function getSessionLabel(sessionId: string): string {
const session = sessions.value.find(s => s.id === sessionId)
if (session?.firstUserMessage) {
const msg = session.firstUserMessage.trim()
return msg.length > 30 ? msg.slice(0, 30) + '...' : msg
}
return sessionId.slice(0, 12) + '...'
}
function startTerminal(sessionId?: string) { function startTerminal(sessionId?: string) {
const key = sessionId || '__new__'
const cmd = sessionId const cmd = sessionId
? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"` ? `${AGENT_CMD[selectedAgent.value]} --resume "${sessionId}"`
: AGENT_CMD[selectedAgent.value] : AGENT_CMD[selectedAgent.value]
const term = useEphemeralTerminal(cmd) const term = useEphemeralTerminal(cmd)
ephemeral.value = term localTerminals.set(key, term)
activeTerminalSessionId.value = key
term.start()
// Register on server
registerTerminalOnServer(
term.ephemeralSessionId,
key,
selectedAgent.value,
sessionId ? getSessionLabel(sessionId) : 'New session',
cmd
)
}
function parkCurrentTerminal() {
const currentId = activeTerminalSessionId.value
if (!currentId) return
const term = localTerminals.get(currentId)
if (term) {
term.park()
localTerminals.delete(currentId)
}
activeTerminalSessionId.value = null
}
function connectToTerminal(transcriptSessionId: string) {
// Find the server registry entry
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
if (!entry) return
// Create local EphemeralTerminal reusing the existing PTY session
const term = useEphemeralTerminal(entry.command, entry.ephemeralSessionId)
localTerminals.set(transcriptSessionId, term)
activeTerminalSessionId.value = transcriptSessionId
term.start() term.start()
} }
async function closeTerminal(transcriptSessionId: string) {
// Find the ephemeralSessionId from registry
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
const ephSid = entry?.ephemeralSessionId
// Dispose local terminal if we have one
const term = localTerminals.get(transcriptSessionId)
if (term) {
await term.dispose()
localTerminals.delete(transcriptSessionId)
} else if (ephSid) {
// No local terminal — kill the PTY directly on server
try {
await fetch(terminalApiUrl('/kill-session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: ephSid })
})
} catch { /* best effort */ }
}
// Unregister if not auto-removed by kill
if (ephSid) {
await unregisterTerminalOnServer(ephSid)
}
await fetchTerminalRegistry()
// If it was active, switch to another or clear
if (activeTerminalSessionId.value === transcriptSessionId) {
activeTerminalSessionId.value = null
const remaining = openTerminals.value.filter(t => t.sessionId !== transcriptSessionId)
if (remaining.length > 0) {
await switchToTerminal(remaining[remaining.length - 1].sessionId)
}
}
}
async function switchToTerminal(transcriptSessionId: string) {
if (transcriptSessionId === activeTerminalSessionId.value) return
// Park current
parkCurrentTerminal()
// Find the entry — might be from another agent
const entry = serverRegistry.value.find(
e => e.transcriptSessionId === transcriptSessionId
)
if (entry && entry.agent !== selectedAgent.value) {
// Switch agent context without disposing terminals
selectedAgent.value = entry.agent as AgentName
await fetchSessions()
}
// Load the target session's transcript
selectedSessionId.value = transcriptSessionId
saveState()
await fetchSessionContent(transcriptSessionId)
// Connect to the terminal
connectToTerminal(transcriptSessionId)
}
async function disposeAllLocalTerminals() {
for (const [, term] of localTerminals) {
await term.dispose()
}
localTerminals.clear()
activeTerminalSessionId.value = null
}
async function createNewSession() { async function createNewSession() {
await disposeTerminal() parkCurrentTerminal()
selectedSessionId.value = null selectedSessionId.value = null
rawContent.value = '' rawContent.value = ''
@@ -158,9 +394,30 @@ export function useTranscriptDebug() {
// Refresh session list (new sessions or size changes) // Refresh session list (new sessions or size changes)
await fetchSessions() await fetchSessions()
// New session just appeared — lock onto it without restarting terminal // Also refresh terminal registry (other clients may have changed things)
await fetchTerminalRegistry()
// New session just appeared — lock onto it, re-key from __new__
if (awaitingNewSession.value) { if (awaitingNewSession.value) {
awaitingNewSession.value = false awaitingNewSession.value = false
// Re-key the __new__ local terminal
const newTerm = localTerminals.get('__new__')
if (newTerm) {
localTerminals.delete('__new__')
localTerminals.set(changedSessionId, newTerm)
// Update server registry with real sessionId
await updateTerminalOnServer(newTerm.ephemeralSessionId, {
transcriptSessionId: changedSessionId,
label: getSessionLabel(changedSessionId)
})
if (activeTerminalSessionId.value === '__new__') {
activeTerminalSessionId.value = changedSessionId
}
}
selectedSessionId.value = changedSessionId selectedSessionId.value = changedSessionId
saveState() saveState()
await fetchSessionContent(changedSessionId) await fetchSessionContent(changedSessionId)
@@ -174,6 +431,15 @@ export function useTranscriptDebug() {
// No session selected yet — auto-select the newest // No session selected yet — auto-select the newest
await selectSession(sessions.value[0].id) await selectSession(sessions.value[0].id)
} }
// Update label for any terminal in the registry matching this session
const entry = serverRegistry.value.find(e => e.transcriptSessionId === changedSessionId)
if (entry) {
const newLabel = getSessionLabel(changedSessionId)
if (entry.label !== newLabel) {
updateTerminalOnServer(entry.ephemeralSessionId, { label: newLabel })
}
}
} }
function handleRealtimeDone(changedSessionId: string) { function handleRealtimeDone(changedSessionId: string) {
@@ -199,17 +465,13 @@ export function useTranscriptDebug() {
m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText) m => m.kind === 'user' && (m as ParsedUserMessage).content.includes(optimisticText)
) )
if (found) { if (found) {
// Real message arrived, drop the optimistic one
optimisticMessage.value = null optimisticMessage.value = null
} else { } else {
// Not yet in JSONL, keep showing it at the end
parsed.messages.push(optimisticMessage.value) parsed.messages.push(optimisticMessage.value)
} }
} }
// Detect turn completion from JSONL content: // Detect turn completion from JSONL content
// If we're processing, the optimistic was replaced (real user entry exists),
// and the last substantive message is an assistant response → turn is done
if (processing.value && !optimisticMessage.value) { if (processing.value && !optimisticMessage.value) {
const lastSubstantive = [...parsed.messages] const lastSubstantive = [...parsed.messages]
.reverse() .reverse()
@@ -225,14 +487,13 @@ export function useTranscriptDebug() {
} }
} }
// Kill terminal on page close/refresh // Park local terminals on page close (don't kill — they persist for other clients)
function onBeforeUnload() { function onBeforeUnload() {
if (ephemeral.value) { for (const [, term] of localTerminals) {
const sessionId = ephemeral.value.ephemeralSessionId // Just park: close WS without killing server PTY
navigator.sendBeacon( try {
terminalApiUrl('/kill-session'), term.park()
JSON.stringify({ sessionId }) } catch { /* best effort */ }
)
} }
} }
@@ -242,12 +503,19 @@ export function useTranscriptDebug() {
// Auto-cleanup on unmount // Auto-cleanup on unmount
onUnmounted(() => { onUnmounted(() => {
disposeTerminal() // Park local terminals (don't kill server PTYs — they're global)
for (const [, term] of localTerminals) {
try { term.park() } catch {}
}
localTerminals.clear()
activeTerminalSessionId.value = null
stopRegistryPolling()
disconnectRealtime() disconnectRealtime()
window.removeEventListener('beforeunload', onBeforeUnload) window.removeEventListener('beforeunload', onBeforeUnload)
}) })
// ── Send prompt (spawns independent claude process) ── // ── Send prompt ──
const sending = ref(false) const sending = ref(false)
const processing = ref(false) const processing = ref(false)
@@ -265,8 +533,6 @@ export function useTranscriptDebug() {
ephemeral.value.sendInput(text) ephemeral.value.sendInput(text)
processing.value = true processing.value = true
// Optimistic: show user message immediately in chat
// Stays until the real user entry appears in the JSONL
optimisticMessage.value = { optimisticMessage.value = {
kind: 'user', kind: 'user',
uuid: `optimistic-${Date.now()}`, uuid: `optimistic-${Date.now()}`,
@@ -296,7 +562,10 @@ export function useTranscriptDebug() {
async function fetchSessionContent(sessionId: string): Promise<boolean> { async function fetchSessionContent(sessionId: string): Promise<boolean> {
try { try {
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${selectedAgent.value}`) // Determine agent from registry if different from selected
const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId)
const agent = entry?.agent || selectedAgent.value
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text() rawContent.value = await res.text()
conversation.value = parseJsonl(rawContent.value, sessionId) conversation.value = parseJsonl(rawContent.value, sessionId)
@@ -309,6 +578,8 @@ export function useTranscriptDebug() {
async function init() { async function init() {
await fetchSessions() await fetchSessions()
await fetchTerminalRegistry()
startRegistryPolling()
let targetSession = selectedSessionId.value let targetSession = selectedSessionId.value
@@ -326,7 +597,17 @@ export function useTranscriptDebug() {
selectedSessionId.value = targetSession selectedSessionId.value = targetSession
saveState() saveState()
await fetchSessionContent(targetSession) await fetchSessionContent(targetSession)
startTerminal(targetSession)
// Check if there's already a terminal for this session in the registry
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === targetSession
)
if (existing && existing.alive) {
// Connect to existing terminal (may have been created by another client)
connectToTerminal(targetSession)
} else {
startTerminal(targetSession)
}
} else { } else {
selectedSessionId.value = null selectedSessionId.value = null
saveState() saveState()
@@ -339,25 +620,35 @@ export function useTranscriptDebug() {
async function switchAgent(agent: AgentName) { async function switchAgent(agent: AgentName) {
if (agent === selectedAgent.value) return if (agent === selectedAgent.value) return
await disposeTerminal() // Park local terminals (don't kill — they're global)
parkCurrentTerminal()
selectedAgent.value = agent selectedAgent.value = agent
error.value = null error.value = null
loading.value = true loading.value = true
transitioning.value = true transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150)) await new Promise(r => setTimeout(r, 150))
rawContent.value = '' rawContent.value = ''
conversation.value = null conversation.value = null
await fetchSessions() await fetchSessions()
await fetchTerminalRegistry()
if (sessions.value.length > 0) { if (sessions.value.length > 0) {
selectedSessionId.value = sessions.value[0].id selectedSessionId.value = sessions.value[0].id
await fetchSessionContent(sessions.value[0].id) await fetchSessionContent(sessions.value[0].id)
startTerminal(sessions.value[0].id)
// Check if there's already a terminal for this session
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessions.value[0].id && e.alive
)
if (existing) {
connectToTerminal(sessions.value[0].id)
} else {
startTerminal(sessions.value[0].id)
}
} else { } else {
selectedSessionId.value = null selectedSessionId.value = null
} }
@@ -370,13 +661,12 @@ export function useTranscriptDebug() {
async function selectSession(sessionId: string) { async function selectSession(sessionId: string) {
if (sessionId === selectedSessionId.value) return if (sessionId === selectedSessionId.value) return
await disposeTerminal() parkCurrentTerminal()
error.value = null error.value = null
loading.value = true loading.value = true
transitioning.value = true transitioning.value = true
// Wait for fade-out
await new Promise(r => setTimeout(r, 150)) await new Promise(r => setTimeout(r, 150))
selectedSessionId.value = sessionId selectedSessionId.value = sessionId
@@ -386,7 +676,15 @@ export function useTranscriptDebug() {
transitioning.value = false transitioning.value = false
loading.value = false loading.value = false
startTerminal(sessionId) // Check if there's already a terminal for this session in the registry
const existing = serverRegistry.value.find(
e => e.transcriptSessionId === sessionId && e.alive
)
if (existing) {
connectToTerminal(sessionId)
} else {
startTerminal(sessionId)
}
} }
// ── JSONL Parser ── // ── JSONL Parser ──
@@ -427,19 +725,11 @@ export function useTranscriptDebug() {
} }
} }
// Build conversation messages
const messages: ConversationMessage[] = [] const messages: ConversationMessage[] = []
// Group assistant entries by message.id (streaming chunks)
const assistantGroups = new Map<string, AssistantEntry[]>() const assistantGroups = new Map<string, AssistantEntry[]>()
// Collect tool results from user entries
const toolResultMap = new Map<string, ParsedToolResult>() const toolResultMap = new Map<string, ParsedToolResult>()
// Collect progress events by toolUseID
const progressByTool = new Map<string, ParsedProgressEvent[]>() const progressByTool = new Map<string, ParsedProgressEvent[]>()
// Helper to build a ParsedProgressEvent from a raw ProgressEntry
function buildProgressEvent(pe: ProgressEntry): ParsedProgressEvent { function buildProgressEvent(pe: ProgressEntry): ParsedProgressEvent {
return { return {
uuid: pe.uuid, uuid: pe.uuid,
@@ -517,7 +807,6 @@ export function useTranscriptDebug() {
continue continue
} }
// Flush progress batch before next non-progress entry
if (currentProgressBatch.length > 0) { if (currentProgressBatch.length > 0) {
messages.push({ messages.push({
kind: 'progress', kind: 'progress',
@@ -566,7 +855,6 @@ export function useTranscriptDebug() {
const group = assistantGroups.get(msgId) const group = assistantGroups.get(msgId)
if (!group) continue if (!group) continue
// Only process first entry in group (we merge all chunks)
if (group[0].uuid !== ae.uuid) continue if (group[0].uuid !== ae.uuid) continue
const textBlocks: string[] = [] const textBlocks: string[] = []
@@ -629,7 +917,6 @@ export function useTranscriptDebug() {
} }
} }
// Flush remaining progress
if (currentProgressBatch.length > 0) { if (currentProgressBatch.length > 0) {
messages.push({ messages.push({
kind: 'progress', kind: 'progress',
@@ -675,11 +962,15 @@ export function useTranscriptDebug() {
ephemeral, ephemeral,
terminalReady, terminalReady,
awaitingNewSession, awaitingNewSession,
openTerminals,
activeTerminalSessionId,
init, init,
fetchSessions, fetchSessions,
switchAgent, switchAgent,
selectSession, selectSession,
createNewSession, createNewSession,
switchToTerminal,
closeTerminal,
connectRealtime, connectRealtime,
disconnectRealtime, disconnectRealtime,
sendPrompt sendPrompt

View File

@@ -1,9 +1,9 @@
/** /**
* useEphemeralTerminal * useEphemeralTerminal
* *
* Lightweight composable for ephemeral terminal sessions. * Composable for terminal sessions that can be parked (WS closed,
* Creates a temporary PTY via WebSocket, runs a command, and * renderer disposed) and later reconnected to the same server PTY
* cleans up completely when disposed (no persistent session). * via buffer replay.
* *
* Used by ResumeTerminalButton to open `<agent> --resume <sessionId>`. * Used by ResumeTerminalButton to open `<agent> --resume <sessionId>`.
*/ */
@@ -32,16 +32,21 @@ export interface EphemeralTerminal {
/** Full cleanup (stop + dispose renderer) */ /** Full cleanup (stop + dispose renderer) */
dispose: () => Promise<void> dispose: () => Promise<void>
/** Park: close WS + dispose renderer, but keep server PTY alive for reconnection */
park: () => void
} }
export function useEphemeralTerminal( export function useEphemeralTerminal(
command: string command: string,
existingSessionId?: string
): EphemeralTerminal { ): EphemeralTerminal {
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const connected = ref(false) const connected = ref(false)
const state = ref<EphemeralState>('off') const state = ref<EphemeralState>('off')
const ephemeralSessionId = `resume-${Date.now()}` const ephemeralSessionId = existingSessionId || `resume-${Date.now()}`
const isReconnect = !!existingSessionId
let socket: WebSocket | null = null let socket: WebSocket | null = null
@@ -104,13 +109,21 @@ export function useEphemeralTerminal(
switch (msg.type) { switch (msg.type) {
case 'connected': case 'connected':
state.value = 'shell-ready' state.value = 'shell-ready'
// Wait for shell prompt to render, then send the command if (isReconnect) {
setTimeout(() => { // Reconnecting to existing PTY — request buffer replay
if (socket?.readyState === WebSocket.OPEN) { if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: command + '\r' })) socket.send(JSON.stringify({ type: 'request-replay', tailOnly: false }))
} }
state.value = 'running' state.value = 'running'
}, 500) } else {
// First time — wait for shell prompt, then send command
setTimeout(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: command + '\r' }))
}
state.value = 'running'
}, 500)
}
break break
case 'replay': case 'replay':
renderer.handleReplay(msg.data || '') renderer.handleReplay(msg.data || '')
@@ -188,6 +201,18 @@ export function useEphemeralTerminal(
renderer.dispose() renderer.dispose()
} }
function park() {
// Close WS without killing server PTY — it stays alive for reconnection
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
connected.value = false
renderer.dispose()
state.value = 'off'
}
return { return {
state, state,
connected, connected,
@@ -197,6 +222,7 @@ export function useEphemeralTerminal(
start, start,
sendInput, sendInput,
stop, stop,
dispose dispose,
park
} }
} }

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useTranscriptDebug } from '@/composables/transcript-debug' import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug' import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug' import type { AgentName } from '@/types/transcript-debug'
@@ -27,6 +28,19 @@ const {
sendPrompt sendPrompt
} = useTranscriptDebug() } = 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 }[] = [ const agents: { id: AgentName; label: string }[] = [
{ id: 'ejecutor', label: 'Ejecutor' }, { id: 'ejecutor', label: 'Ejecutor' },
{ id: 'nucleo000', label: 'nucleo000' }, { id: 'nucleo000', label: 'nucleo000' },
@@ -34,6 +48,7 @@ const agents: { id: AgentName; label: string }[] = [
] ]
function handleSend(message: string) { function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message) sendPrompt(message)
} }
@@ -41,12 +56,14 @@ function handleCreateSession() {
createNewSession() createNewSession()
} }
onMounted(() => { onMounted(async () => {
init() init()
await voice.init()
}) })
onUnmounted(() => { onUnmounted(() => {
disconnectRealtime() disconnectRealtime()
voice.cleanup()
}) })
</script> </script>
@@ -119,8 +136,21 @@ onUnmounted(() => {
:terminal-ready="terminalReady" :terminal-ready="terminalReady"
:terminal="ephemeral" :terminal="ephemeral"
:selected-agent="selectedAgent" :selected-agent="selectedAgent"
: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"
@send="handleSend" @send="handleSend"
@create-session="handleCreateSession" @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()"
/> />
</div> </div>
</div> </div>

View File

@@ -18,8 +18,6 @@
html, body { html, body {
height: 100%; height: 100%;
min-height: 100vh;
min-height: 100dvh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
@@ -28,10 +26,13 @@ html, body {
overflow: hidden; overflow: hidden;
} }
body {
position: fixed;
width: 100%;
}
#app { #app {
height: 100%; height: 100%;
min-height: 100vh;
min-height: 100dvh;
} }
/* Scrollbar styling */ /* Scrollbar styling */

View File

@@ -216,6 +216,19 @@ export interface ParsedSystemMessage {
subtype?: string subtype?: string
} }
// ── Terminal slot (persistent terminal registry) ──
export interface TerminalSlot {
sessionId: string // Claude transcript session ID (transcriptSessionId)
ephemeralSessionId: string // PTY session ID on server (for reconnection)
agent: AgentName
label: string // firstUserMessage (truncated) or "New session"
command: string // The command used to start
active: boolean // Is this the currently displayed terminal (for this client)?
alive: boolean // PTY exists on server
clients: number // Number of connected WS clients
}
// ── Raw entry union ── // ── Raw entry union ──
export type TranscriptEntry = export type TranscriptEntry =

View File

@@ -33,6 +33,47 @@ const sessions = new Map<string, TerminalSession>()
// Map WebSocket to sessionId // Map WebSocket to sessionId
const wsToSession = new Map<any, string>() const wsToSession = new Map<any, string>()
// ── Global terminal registry ──
// Tracks metadata about transcript-debug terminals so all clients can see/connect to them
interface TerminalRegistryEntry {
ephemeralSessionId: string // PTY session ID on this server
transcriptSessionId: string // Claude transcript session being resumed (or '__new__')
agent: string // ejecutor | nucleo000 | claude
label: string // First user message or short description
command: string // Full command that was run
createdAt: string // ISO timestamp
}
const terminalRegistry = new Map<string, TerminalRegistryEntry>() // keyed by ephemeralSessionId
function getRegistrySnapshot() {
return Array.from(terminalRegistry.values()).map(entry => {
const ptySession = sessions.get(entry.ephemeralSessionId)
return {
...entry,
alive: !!ptySession,
clients: ptySession?.clients.size ?? 0,
bufferSize: ptySession?.outputBuffer.length ?? 0
}
})
}
function broadcastRegistryChange() {
const message = JSON.stringify({
type: 'terminal-registry-change',
registry: getRegistrySnapshot(),
timestamp: Date.now()
})
let clientCount = 0
for (const [, session] of sessions) {
for (const ws of session.clients) {
try { ws.send(message); clientCount++ } catch { /* skip */ }
}
}
console.log(`[Terminal] Registry broadcast → ${clientCount} clients (${terminalRegistry.size} entries)`)
}
function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession { function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession {
let session = sessions.get(sessionId) let session = sessions.get(sessionId)
@@ -83,6 +124,12 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
} }
sessions.delete(sessionId) sessions.delete(sessionId)
// Auto-remove from terminal registry
if (terminalRegistry.has(sessionId)) {
terminalRegistry.delete(sessionId)
broadcastRegistryChange()
}
// Mark agent as not running if this is an agent session // Mark agent as not running if this is an agent session
if (sessionId.startsWith('agent-')) { if (sessionId.startsWith('agent-')) {
const agentId = sessionId.replace('agent-', '') const agentId = sessionId.replace('agent-', '')
@@ -122,6 +169,13 @@ export function killSession(sessionId: string): boolean {
} }
sessions.delete(sessionId) sessions.delete(sessionId)
// Auto-remove from terminal registry
if (terminalRegistry.has(sessionId)) {
terminalRegistry.delete(sessionId)
broadcastRegistryChange()
}
return true return true
} }
@@ -301,6 +355,67 @@ export function startTerminalServer() {
} }
} }
// ── Terminal Registry endpoints ──
// List all registered terminals (global, for all clients)
if (url.pathname === '/terminal-registry' && req.method === 'GET') {
return Response.json({ registry: getRegistrySnapshot() }, { headers: corsHeaders })
}
// Register a new terminal
if (url.pathname === '/register-terminal' && req.method === 'POST') {
try {
const body = await req.json() as TerminalRegistryEntry
if (!body.ephemeralSessionId) {
return Response.json({ error: 'ephemeralSessionId required' }, { status: 400, headers: corsHeaders })
}
terminalRegistry.set(body.ephemeralSessionId, {
ephemeralSessionId: body.ephemeralSessionId,
transcriptSessionId: body.transcriptSessionId || '',
agent: body.agent || '',
label: body.label || '',
command: body.command || '',
createdAt: body.createdAt || new Date().toISOString()
})
console.log(`[Terminal] Registered terminal: ${body.ephemeralSessionId}${body.transcriptSessionId} (${body.agent})`)
broadcastRegistryChange()
return Response.json({ success: true }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Update a registered terminal (e.g. re-key transcriptSessionId, update label)
if (url.pathname === '/update-terminal' && req.method === 'POST') {
try {
const body = await req.json() as Partial<TerminalRegistryEntry> & { ephemeralSessionId: string }
const entry = terminalRegistry.get(body.ephemeralSessionId)
if (!entry) {
return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders })
}
if (body.transcriptSessionId !== undefined) entry.transcriptSessionId = body.transcriptSessionId
if (body.label !== undefined) entry.label = body.label
if (body.agent !== undefined) entry.agent = body.agent
if (body.command !== undefined) entry.command = body.command
broadcastRegistryChange()
return Response.json({ success: true }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Unregister a terminal (does NOT kill the PTY — use /kill-session for that)
if (url.pathname === '/unregister-terminal' && req.method === 'POST') {
try {
const body = await req.json() as { ephemeralSessionId: string }
const deleted = terminalRegistry.delete(body.ephemeralSessionId)
if (deleted) broadcastRegistryChange()
return Response.json({ success: true, deleted }, { headers: corsHeaders })
} catch (e: any) {
return Response.json({ error: e.message }, { status: 400, headers: corsHeaders })
}
}
// Check if this is a WebSocket upgrade request // Check if this is a WebSocket upgrade request
const upgradeHeader = req.headers.get('upgrade') const upgradeHeader = req.headers.get('upgrade')
console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`)

View File

@@ -1,248 +0,0 @@
## ============================================
## Agent UI - Traefik Dynamic Configuration
## ============================================
##
## INSTALACIÓN:
## 1. Copia este archivo a tu directorio de config dinámica de Traefik
## Ejemplo: /etc/traefik/dynamic/agent-ui.yml
##
## 2. Asegúrate de tener el file provider habilitado en traefik.yml:
## providers:
## file:
## directory: /etc/traefik/dynamic
## watch: true
##
## 3. Asegúrate de tener el certResolver configurado (letsencrypt)
##
## ARQUITECTURA:
## ┌─────────────────────────────────────────────────────┐
## │ https://z590.nucleoriofrio.com │
## │ / → Frontend (4100) │
## │ /api/* → API Server (4101) │
## │ /ws/terminal→ Terminal WebSocket (4103) │
## │ /ws/status → Claude Status WebSocket (4103) │
## │ /ws/mcp → WebMCP WebSocket (4102) │
## │ /ws/whisper → Whisper WebSocket (4104) │
## └─────────────────────────────────────────────────────┘
##
http:
## ============================================
## MIDDLEWARES
## ============================================
middlewares:
## ----------------------------------------
## IP Whitelist (DESCOMENTEAR PARA ACTIVAR)
## ----------------------------------------
## Para activar: descomenta este bloque y agrega
## "agentui-ipwhitelist" a la lista de middlewares
## de cada router.
##
# agentui-ipwhitelist:
# ipWhiteList:
# sourceRange:
# - "192.168.87.0/24" # Red local del servidor
# - "192.168.1.0/24" # Otra red local
# - "10.0.0.0/8" # Redes internas
# - "YOUR_PUBLIC_IP/32" # Tu IP pública específica
# # Si hay proxies intermedios, usa ipStrategy:
# # ipStrategy:
# # depth: 1
## ----------------------------------------
## Strip Prefix para WebSockets
## ----------------------------------------
## Traefik recibe /ws/terminal pero el backend espera /
strip-ws-terminal:
stripPrefix:
prefixes:
- "/ws/terminal"
strip-ws-status:
stripPrefix:
prefixes:
- "/ws/status"
strip-ws-mcp:
stripPrefix:
prefixes:
- "/ws/mcp"
strip-ws-whisper:
stripPrefix:
prefixes:
- "/ws/whisper"
## ----------------------------------------
## Headers de seguridad
## ----------------------------------------
agentui-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
# Headers de seguridad opcionales:
# stsSeconds: 31536000
# stsIncludeSubdomains: true
# contentTypeNosniff: true
# browserXssFilter: true
## ============================================
## ROUTERS
## ============================================
routers:
## ----------------------------------------
## Frontend (/) - Vue App
## ----------------------------------------
## Sirve la aplicación Vue, assets, PWA manifest, etc.
## Excluye /api y /ws para que los otros routers los manejen.
##
agentui-frontend:
rule: "Host(`z590.nucleoriofrio.com`) && !PathPrefix(`/api`) && !PathPrefix(`/ws`)"
entryPoints:
- websecure
service: agentui-frontend
middlewares:
- agentui-headers
# - agentui-ipwhitelist # Descomentar para activar
tls:
certResolver: letsencrypt
priority: 1
## ----------------------------------------
## API Backend (/api/*) - REST API
## ----------------------------------------
## Maneja todas las llamadas REST:
## - /api/health - Health check
## - /api/themes/* - Sistema de temas
## - /api/components/* - Componentes Vue guardados
## - /api/database/* - Explorador de base de datos
## - /api/claude-status - Status de Claude (desde hooks)
## - /api/whisper/* - Control de Whisper
## - etc.
##
agentui-api:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/api`)"
entryPoints:
- websecure
service: agentui-api
middlewares:
- agentui-headers
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 10
## ----------------------------------------
## Terminal WebSocket (/ws/terminal)
## ----------------------------------------
## Conexión WebSocket para xterm.js
## Permite ejecutar comandos en el servidor.
##
agentui-ws-terminal:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/terminal`)"
entryPoints:
- websecure
service: agentui-terminal
middlewares:
- strip-ws-terminal
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## Claude Status WebSocket (/ws/status)
## ----------------------------------------
## Recibe actualizaciones de estado de Claude/Nucleo
## para las animaciones del FAB.
## Usa el mismo backend que terminal (4103).
##
agentui-ws-status:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/status`)"
entryPoints:
- websecure
service: agentui-terminal
middlewares:
- strip-ws-status
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## WebMCP WebSocket (/ws/mcp)
## ----------------------------------------
## Bridge entre Claude Code MCP y el browser.
## Permite que Claude ejecute herramientas en la UI:
## - render_vue_component
## - navigate_to
## - set_theme_variable
## - etc.
##
agentui-ws-mcp:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/mcp`)"
entryPoints:
- websecure
service: agentui-mcp
middlewares:
- strip-ws-mcp
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ----------------------------------------
## Whisper WebSocket (/ws/whisper)
## ----------------------------------------
## Transcripción de voz con Whisper.
## Opcional - solo si usas la función de voz.
##
agentui-ws-whisper:
rule: "Host(`z590.nucleoriofrio.com`) && PathPrefix(`/ws/whisper`)"
entryPoints:
- websecure
service: agentui-whisper
middlewares:
- strip-ws-whisper
# - agentui-ipwhitelist
tls:
certResolver: letsencrypt
priority: 20
## ============================================
## SERVICES (Backends)
## ============================================
## Cambia 192.168.87.135 por la IP de tu servidor
## si es diferente.
##
services:
## Frontend - Vite dev server o build estático
agentui-frontend:
loadBalancer:
servers:
- url: "http://192.168.87.135:4100"
## API Server - Bun HTTP
agentui-api:
loadBalancer:
servers:
- url: "http://192.168.87.135:4101"
## Terminal WebSocket Server
## También maneja Claude status broadcast
agentui-terminal:
loadBalancer:
servers:
- url: "http://192.168.87.135:4103"
## WebMCP WebSocket Server
agentui-mcp:
loadBalancer:
servers:
- url: "http://192.168.87.135:4102"
## Whisper WebSocket Server (opcional)
agentui-whisper:
loadBalancer:
servers:
- url: "http://192.168.87.135:4104"