feat: Samsung lock screen face widget, voice assistant services, PiP mode and gitignore installers

Add Samsung proprietary Face Widget (lock screen/AOD) with terminal status display.
Add voice interaction services (AgentVoiceInteractionService, RecognitionService) for
digital assistant registration. Add PiP mode with voice/expand actions. Add session-state
proxy, voice transcript routes, window controls component. Ignore installers/ directory.
This commit is contained in:
2026-02-23 20:52:11 -06:00
parent e1aa8b1bdb
commit 65303df96a
35 changed files with 2640 additions and 484 deletions

View File

@@ -11,6 +11,7 @@ import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue
import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue'
import ServerConfigDialog from './components/ServerConfigDialog.vue'
import WindowControls from './components/WindowControls.vue'
import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch'
@@ -20,7 +21,7 @@ import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state'
import { isTauri } from './lib/tauri'
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
import { useServerConfig } from './stores/server-config'
const route = useRoute()
@@ -35,7 +36,7 @@ const showVoice = ref(false)
const showTranscriptDebug = ref(false)
const showDebugConsole = ref(false)
const toolbarVisible = ref(true)
const forceWco = ref(false)
const forceWco = ref(isTauri && !isMobileTauri())
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
// Intercept console.log for debug panel
@@ -222,6 +223,31 @@ function syncThemeColor() {
}
onMounted(async () => {
// Bridge for Android widget navigation (called from MainActivity via evaluateJavascript)
;(window as any).__WIDGET_NAVIGATE__ = (route: string) => {
router.push(route)
return true
}
// Bridge for Android voice assistant — opens FloatingTranscriptDebug on the target terminal
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
const entry = sessionState.terminalRegistry.find(
t => t.ephemeralSessionId === ephemeralSessionId
)
if (entry && transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(entry.transcriptSessionId)
showTranscriptDebug.value = true
return true
}
// Fallback: open on first terminal
if (sessionState.terminalRegistry.length && transcriptDebugRef.value) {
transcriptDebugRef.value.switchToTerminal(sessionState.terminalRegistry[0].transcriptSessionId)
showTranscriptDebug.value = true
return true
}
return false
}
// Sync Windows titlebar color with CSS variable
syncThemeColor()
@@ -287,6 +313,34 @@ onMounted(async () => {
await torchReady
})
async function sendTestNotification() {
const title = 'Agent UI'
const body = 'Test notification from Agent UI — all platforms!'
if (isTauri) {
try {
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
let granted = await isPermissionGranted()
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
}
if (granted) {
sendNotification({ title, body })
}
} catch (e) {
console.warn('[Notification] Tauri plugin failed:', e)
}
} else if ('Notification' in window) {
if (Notification.permission === 'granted') {
new Notification(title, { body })
} else if (Notification.permission !== 'denied') {
const perm = await Notification.requestPermission()
if (perm === 'granted') new Notification(title, { body })
}
}
}
onUnmounted(() => {
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleGlobalKeydown)
@@ -367,6 +421,12 @@ if (serverConfig) {
</svg>
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
</button>
<button class="refresh-btn" @click="sendTestNotification" title="Test notification">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
@@ -375,6 +435,7 @@ if (serverConfig) {
</button>
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
<TorchButton />
<WindowControls />
</div>
</header>
<main class="app-main">
@@ -503,9 +564,9 @@ if (serverConfig) {
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
padding-top: calc(0.5rem + var(--sat, env(safe-area-inset-top, 0px)));
padding-left: calc(1rem + var(--sal, env(safe-area-inset-left, 0px)));
padding-right: calc(1rem + var(--sar, env(safe-area-inset-right, 0px)));
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
@@ -537,6 +598,7 @@ if (serverConfig) {
min-height: 32px;
max-height: 32px;
padding: 0 0.5rem;
padding-right: 0;
border-bottom: none;
overflow: visible;
}

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { isTauri, isMobileTauri, getTauriWindow } from '../lib/tauri'
const isDesktopTauri = isTauri && !isMobileTauri()
const isMaximized = ref(false)
let unlisten: (() => void) | null = null
async function initWindowState() {
if (!isDesktopTauri) return
try {
const win = await getTauriWindow()
isMaximized.value = await win.isMaximized()
unlisten = await win.onResized(async () => {
isMaximized.value = await win.isMaximized()
})
} catch (e) {
console.warn('[WindowControls] init failed:', e)
}
}
async function minimizeWindow() {
try {
const win = await getTauriWindow()
await win.minimize()
} catch (e) {
console.warn('[WindowControls] minimize failed:', e)
}
}
async function toggleMaximize() {
try {
const win = await getTauriWindow()
await win.toggleMaximize()
} catch (e) {
console.warn('[WindowControls] toggleMaximize failed:', e)
}
}
async function closeWindow() {
try {
const win = await getTauriWindow()
await win.close()
} catch (e) {
console.warn('[WindowControls] close failed:', e)
}
}
onMounted(() => {
initWindowState()
})
onUnmounted(() => {
if (unlisten) {
unlisten()
unlisten = null
}
})
</script>
<template>
<div v-if="isDesktopTauri" class="window-controls">
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor" />
</svg>
</button>
<button class="wc-btn wc-maximize" @click="toggleMaximize" :title="isMaximized ? 'Restore' : 'Maximize'">
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10">
<rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1" />
</svg>
<svg v-else width="10" height="10" viewBox="0 0 10 10">
<rect x="2.5" y="0.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1" />
<rect x="0.5" y="2.5" width="7" height="7" fill="var(--bg-primary, #0f0f14)" stroke="currentColor" stroke-width="1" />
</svg>
</button>
<button class="wc-btn wc-close" @click="closeWindow" title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
</div>
</template>
<style scoped>
.window-controls {
display: flex;
align-items: stretch;
height: 100%;
margin-left: 0.5rem;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.wc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
min-height: 32px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary, #a1a1aa);
cursor: pointer;
transition: background 0.1s ease, color 0.1s ease;
outline: none;
}
.wc-btn:hover {
background: var(--bg-hover, #1e1e28);
color: var(--text-primary, #e4e4e7);
}
.wc-btn:active {
background: var(--border-color, #2a2a3a);
}
.wc-close:hover {
background: #e81123;
color: #ffffff;
}
.wc-close:active {
background: #c50f1f;
color: #ffffff;
}
</style>

View File

@@ -83,3 +83,8 @@ export async function getTauriClipboard() {
export async function getTauriDialog() {
return import('@tauri-apps/plugin-dialog')
}
export async function getTauriWindow() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
return getCurrentWindow()
}

View File

@@ -1,30 +1,38 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
import { useSessionState } from '@/stores/session-state'
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
const route = useRoute()
const router = useRouter()
const sessionState = useSessionState()
const {
selectedAgent,
sessions,
selectedSessionId,
rawContent,
conversation,
loading,
transitioning,
transitionError,
error,
lineCount,
isRealtime,
sending,
processing,
ephemeral,
terminalReady,
hookMeta,
openTerminals,
activeTerminalSessionId,
init,
switchAgent,
selectSession,
createNewSession,
switchToTerminal,
closeTerminal,
disconnectRealtime,
sendPrompt
} = useTranscriptDebug()
@@ -48,21 +56,117 @@ const agents: { id: AgentName; label: string }[] = [
{ id: 'claude', label: 'Claude' }
]
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
const showSelector = ref(false)
const showNewSessionModal = ref(false)
// Readability overlay
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 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))
}
// Scroll nav mode
type ScrollNavMode = 'scrollbar' | 'buttons' | 'none'
const savedScrollNav = localStorage.getItem('transcript-scroll-nav') as ScrollNavMode | null
const scrollNavMode = ref<ScrollNavMode>(
savedScrollNav && ['scrollbar', 'buttons', 'none'].includes(savedScrollNav) ? savedScrollNav : 'buttons'
)
function setScrollNavMode(val: ScrollNavMode) {
scrollNavMode.value = val
localStorage.setItem('transcript-scroll-nav', val)
}
// Scroll jump percent
const savedScrollJump = localStorage.getItem('transcript-scroll-jump')
const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50)
function setScrollJumpPercent(val: number) {
scrollJumpPercent.value = val
localStorage.setItem('transcript-scroll-jump', String(val))
}
// Active terminal index label
const terminalLabel = computed(() => {
if (!activeTerminalSessionId.value) return null
const idx = sessionState.terminalRegistry.findIndex(
e => e.transcriptSessionId === activeTerminalSessionId.value
)
return idx >= 0 ? `T${idx + 1}` : null
})
function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message)
}
function handleCreateSession() {
createNewSession()
function handleAgentSwitch(agent: AgentName) {
switchAgent(agent)
}
onMounted(async () => {
init()
await voice.init()
function handleSessionSelect(sessionId: string) {
selectSession(sessionId)
showSelector.value = false
}
function handleCreateSession() {
showNewSessionModal.value = true
}
async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
switchAgent(agent)
}
await createNewSession()
if (initialPrompt.trim()) {
sendPrompt(initialPrompt.trim())
}
}
function handleTerminalSwitch(sessionId: string) {
const idx = sessionState.terminalRegistry.findIndex(
e => e.transcriptSessionId === sessionId
)
if (idx >= 0) {
router.push({ name: 'transcript-debug-terminal', params: { terminalIndex: String(idx + 1) } })
}
}
// Resolve terminalIndex param → sessionId and switch
function syncTerminalFromRoute() {
const param = route.params.terminalIndex
if (!param) return
const idx = parseInt(param as string) - 1
const entry = sessionState.terminalRegistry[idx]
if (entry && entry.transcriptSessionId !== activeTerminalSessionId.value) {
switchToTerminal(entry.transcriptSessionId)
}
}
watch(() => route.params.terminalIndex, () => syncTerminalFromRoute())
// Also react when registry populates (it may arrive after mount)
watch(() => sessionState.terminalRegistry.length, () => {
if (route.params.terminalIndex) syncTerminalFromRoute()
})
onUnmounted(() => {
onMounted(async () => {
await init()
await voice.init()
syncTerminalFromRoute()
})
onBeforeUnmount(() => {
disconnectRealtime()
voice.cleanup()
})
@@ -70,93 +174,166 @@ onUnmounted(() => {
<template>
<div class="transcript-debug-page">
<!-- Header -->
<header class="page-header">
<div class="header-left">
<h2>Transcript Debug</h2>
<span :class="['realtime-dot', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
</span>
<!-- Terminal selector strip -->
<div class="terminal-strip">
<div class="strip-left">
<button
v-for="(entry, idx) in sessionState.terminalRegistry"
:key="entry.transcriptSessionId"
:class="[
'strip-terminal-btn',
{
active: String(idx + 1) === route.params.terminalIndex || (!route.params.terminalIndex && entry.transcriptSessionId === activeTerminalSessionId),
dead: !entry.alive
}
]"
@click="handleTerminalSwitch(entry.transcriptSessionId)"
:title="entry.label"
>
<span :class="['strip-dot', { alive: entry.alive, dead: !entry.alive }]"></span>
T{{ idx + 1 }}
</button>
</div>
<div class="header-selectors">
<!-- Agent selector -->
<div class="agent-selector">
<button
v-for="a in agents"
:key="a.id"
:class="['agent-btn', { active: selectedAgent === a.id }]"
@click="switchAgent(a.id)"
>
{{ a.label }}
</button>
</div>
<!-- Session selector -->
<SessionSelector
:sessions="sessions"
:selected-id="selectedSessionId"
:loading="loading"
@select="selectSession"
<div class="strip-right">
<AgentBadge
v-if="selectedAgent"
:agent="selectedAgent"
:connected="!!activeTerminalSessionId"
:terminals="openTerminals"
:active-session-id="activeTerminalSessionId"
:model="conversation?.model"
:version="conversation?.version"
@switch-terminal="handleTerminalSwitch"
@close-terminal="closeTerminal"
/>
<span :class="['realtime-dot', { connected: isRealtime }]">
<svg width="6" height="6" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
</span>
<button
@click.stop="chatRef?.collapseAllExceptLast()"
:class="['strip-btn', { active: chatRef?.allCollapsed }]"
title="Collapse all except last"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
<template v-else><polyline points="18 15 12 9 6 15"/></template>
</svg>
</button>
<button
@click.stop="chatRef?.toggleSelectMode()"
:class="['strip-btn', { active: chatRef?.selectMode }]"
title="Select messages"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
<template v-else>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</template>
</svg>
</button>
<button
@click.stop="showSelector = !showSelector"
:class="['strip-btn', { active: showSelector }]"
title="Agent/Session selector"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</header>
</div>
<!-- Error -->
<div v-if="error" class="error-bar">{{ error }}</div>
<!-- Content -->
<div class="content-area">
<div v-if="!selectedSessionId" :class="['empty-state', { fading: transitioning }]">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
<div :class="['content-area', { 'selector-open': showSelector }]">
<AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<Transition name="terminal-loading">
<div v-if="transitioning" class="loading-overlay">
<div class="loading-spinner" />
</div>
</Transition>
<Transition name="terminal-loading">
<div v-if="transitionError" class="error-overlay" @click="transitionError = null">
<div class="error-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2">
<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 class="error-msg">{{ transitionError }}</span>
<span class="error-hint">Click to dismiss</span>
</div>
</div>
</Transition>
<ChatContainer
ref="chatRef"
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
:sessions="sessions"
:selected-session-id="selectedSessionId"
:sessions-loading="loading"
:voice-mode="voiceMode"
:whisper-status="whisperStatus"
:audio-devices="audioDevices"
:selected-device-id="selectedDeviceId"
:is-recording="voiceRecording"
:voice-transcript="voiceTranscript + voiceInterim"
:last-audio-url="lastAudioUrl"
:is-playing-audio="isPlayingAudio"
:overlay-opacity="overlayOpacity"
:input-max-lines="inputMaxLines"
:scroll-jump-percent="scrollJumpPercent"
:scroll-nav-mode="scrollNavMode"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
@close-session="closeTerminal"
@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"
@update:scroll-jump-percent="setScrollJumpPercent"
@update:scroll-nav-mode="setScrollNavMode"
/>
<div v-else-if="!transitioning" class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<p>Select a transcript session to begin</p>
<span>{{ sessions.length }} sessions available</span>
</div>
<div v-else class="split-panels">
<!-- Left: Raw JSONL -->
<div :class="['panel-left', { fading: transitioning }]">
<RawJsonViewer :content="rawContent" />
</div>
<!-- Resize handle -->
<div class="resize-handle"></div>
<!-- Right: Chat -->
<div :class="['panel-right', { fading: transitioning }]">
<ChatContainer
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
: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"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@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>
<span>No active terminal</span>
<small v-if="!sessionState.terminalRegistry.length">No terminals registered</small>
<small v-else>Select a terminal above to begin</small>
</div>
</div>
<!-- New session modal -->
<NewSessionModal
v-if="showNewSessionModal"
:agents="agents"
:default-agent="selectedAgent"
@create="handleModalCreateNew"
@close="showNewSessionModal = false"
/>
</div>
</template>
@@ -170,29 +347,70 @@ onUnmounted(() => {
color: var(--text-primary);
}
.page-header {
/* ── Terminal strip ── */
.terminal-strip {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
padding: 3px 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 1rem;
gap: 0.5rem;
min-height: 32px;
}
.header-left {
.strip-left {
display: flex;
align-items: center;
gap: 3px;
}
.strip-terminal-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
font-family: 'Courier New', monospace;
cursor: pointer;
transition: all 0.15s;
}
.strip-terminal-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--accent, #0ea5e9);
}
.strip-terminal-btn.active {
background: var(--accent, #0ea5e9);
color: white;
border-color: var(--accent, #0ea5e9);
}
.strip-terminal-btn.dead:not(.active) {
opacity: 0.5;
}
.strip-dot {
width: 5px;
height: 5px;
border-radius: 50%;
}
.strip-dot.alive { background: #22c55e; }
.strip-dot.dead { background: #ef4444; }
.strip-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.header-left h2 {
font-size: 15px;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.realtime-dot {
@@ -200,7 +418,6 @@ onUnmounted(() => {
opacity: 0.4;
transition: all 0.3s;
}
.realtime-dot.connected {
color: #22c55e;
opacity: 1;
@@ -212,50 +429,30 @@ onUnmounted(() => {
50% { filter: drop-shadow(0 0 6px currentColor); }
}
.header-selectors {
.strip-btn {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
/* Agent selector */
.agent-selector {
display: flex;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.agent-btn {
padding: 0.35rem 0.75rem;
background: transparent;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.agent-btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.agent-btn:hover {
.strip-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.agent-btn.active {
background: var(--accent);
.strip-btn.active {
background: var(--accent, #0ea5e9);
color: white;
}
/* ── Error bar ── */
.error-bar {
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.1);
@@ -265,18 +462,83 @@ onUnmounted(() => {
flex-shrink: 0;
}
/* ── Content area (mirrors FloatingTranscriptDebug .content) ── */
.content-area {
display: flex;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
.fading {
opacity: 0 !important;
.readability-overlay {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
/* ── Loading / Error overlays ── */
.loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
}
.loading-spinner {
width: 28px;
height: 28px;
border: 2.5px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.7);
border-radius: 50%;
animation: tl-spin 0.7s linear infinite;
}
@keyframes tl-spin { to { transform: rotate(360deg); } }
.terminal-loading-enter-active { transition: opacity 0.15s ease; }
.terminal-loading-leave-active { transition: opacity 0.25s ease; }
.terminal-loading-enter-from,
.terminal-loading-leave-to { opacity: 0; }
.error-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
cursor: pointer;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
max-width: 80%;
padding: 1.2rem 1.5rem;
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 10px;
}
.error-msg { font-size: 12px; color: #fca5a5; text-align: center; line-height: 1.4; word-break: break-word; }
.error-hint { font-size: 10px; color: rgba(255, 255, 255, 0.35); }
/* ── Empty state ── */
.empty-state {
transition: opacity 0.15s ease;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
@@ -285,103 +547,218 @@ onUnmounted(() => {
gap: 0.75rem;
color: var(--text-muted);
}
.empty-state svg { opacity: 0.4; }
.empty-state span { font-size: 15px; color: var(--text-secondary); }
.empty-state small { font-size: 13px; }
.empty-state svg {
opacity: 0.4;
/* ══════════════════════════════════════════════════════════════════════════════
ChatContainer glass-transparent overrides (mirrored from FloatingTranscriptDebug)
══════════════════════════════════════════════════════════════════════════════ */
.content-area :deep(.chat-container) {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
position: relative;
z-index: 1;
flex: 1 !important;
min-height: 0 !important;
}
.empty-state p {
font-size: 15px;
color: var(--text-secondary);
margin: 0;
/* Chat header: absolute overlay, floats over messages */
.content-area :deep(.chat-header) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 0.3rem 0.6rem !important;
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
.empty-state span {
font-size: 13px;
/* Hidden by default, shown only when selector-open */
.content-area:not(.selector-open) :deep(.chat-header) {
opacity: 0 !important;
transform: translateY(-150%) !important;
pointer-events: none !important;
}
.split-panels {
display: flex;
flex: 1;
overflow: hidden;
/* Messages: fill entire container, pad for overlaid header / input */
.content-area :deep(.messages-scroll) {
background: transparent !important;
padding-top: 3.5rem !important;
padding-bottom: 5rem !important;
flex: 1 !important;
}
.panel-left {
width: 35%;
min-width: 250px;
display: flex;
flex-direction: column;
padding: 0.5rem;
padding-right: 0;
transition: opacity 0.15s ease;
/* Bottom overlay: absolute container for lifecycle + input + status */
.content-area :deep(.bottom-overlay) {
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
display: flex !important;
flex-direction: column !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
}
.resize-handle {
width: 4px;
cursor: col-resize;
background: transparent;
flex-shrink: 0;
margin: 0.5rem 2px;
border-radius: 2px;
transition: background 0.15s;
.content-area :deep(.status-bar) {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
border-bottom: none !important;
padding: 0.15rem 0.5rem !important;
}
.resize-handle:hover {
background: var(--accent);
.content-area :deep(.status-id) {
color: rgba(255,255,255,0.35) !important;
}
.panel-right {
flex: 1;
min-width: 300px;
display: flex;
flex-direction: column;
padding: 0.5rem;
padding-left: 0;
transition: opacity 0.15s ease;
.content-area :deep(.status-bar .copy-id-btn) {
color: rgba(255,255,255,0.25) !important;
}
.content-area :deep(.status-bar .copy-id-btn:hover) {
color: rgba(255,255,255,0.6) !important;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.content-area :deep(.status-bar .meta-badge) {
border-radius: 0 !important;
font-family: 'Courier New', monospace !important;
}
.header-left h2 {
font-size: 14px;
}
.content-area :deep(.status-bar .meta-badge.model) {
background: rgba(99, 102, 241, 0.1) !important;
color: #a5b4fc !important;
}
.header-selectors {
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 0.5rem;
}
.content-area :deep(.status-bar .meta-badge.version) {
background: rgba(255,255,255,0.04) !important;
color: rgba(255,255,255,0.3) !important;
}
.split-panels {
flex-direction: column;
}
.content-area :deep(.status-bar .meta-count),
.content-area :deep(.status-bar .meta-duration) {
color: rgba(255,255,255,0.2) !important;
}
.panel-left {
width: 100%;
height: 40%;
min-width: 0;
padding: 0.5rem;
}
/* UserInput */
.content-area :deep(.user-input) {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
padding: 0.3rem 0.5rem !important;
}
.resize-handle {
width: 100%;
height: 4px;
margin: 2px 0.5rem;
cursor: row-resize;
}
/* Lifecycle ribbon */
.content-area :deep(.lifecycle-ribbon) {
background: transparent !important;
pointer-events: none !important;
}
.panel-right {
flex: 1;
min-width: 0;
min-height: 0;
padding: 0.5rem;
}
/* Input container */
.content-area :deep(.input-container) {
background: rgba(0, 6, 18, 0.8) !important;
border-color: rgba(14, 165, 233, 0.1) !important;
border-radius: 0 !important;
padding: 0.3rem 0.4rem !important;
}
.content-area :deep(.input-container:focus-within) {
border-color: rgba(14, 165, 233, 0.25) !important;
background: rgba(0, 6, 18, 0.85) !important;
}
.content-area :deep(.input-field) {
color: rgba(255,255,255,0.85);
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* Send button: pixel art daytime ocean */
.content-area :deep(.send-btn) {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' shape-rendering='crispEdges'%3E%3Crect width='28' height='6' fill='%2387ceeb'/%3E%3Crect y='6' width='28' height='4' fill='%2356b3d9'/%3E%3Crect y='10' width='28' height='4' fill='%232d9abf'/%3E%3Crect y='14' width='28' height='4' fill='%231a7fa5'/%3E%3Crect y='18' width='28' height='4' fill='%23106888'/%3E%3Crect y='22' width='28' height='6' fill='%23c2b280'/%3E%3Crect x='24' y='4' width='3' height='3' fill='%23fffde0' opacity='0.8'/%3E%3Crect x='25' y='3' width='2' height='1' fill='%23fffde0' opacity='0.5'/%3E%3Crect x='2' y='5' width='4' height='2' fill='white' opacity='0.35'/%3E%3Crect x='10' y='4' width='6' height='2' fill='white' opacity='0.25'/%3E%3Crect x='20' y='6' width='3' height='1' fill='white' opacity='0.2'/%3E%3Crect x='5' y='12' width='3' height='2' fill='%23f97316' opacity='0.7'/%3E%3Crect x='4' y='13' width='1' height='1' fill='%23fdba74' opacity='0.5'/%3E%3Crect x='18' y='16' width='2' height='1' fill='%232563eb' opacity='0.5'/%3E%3Crect x='20' y='16' width='1' height='1' fill='%2393c5fd' opacity='0.4'/%3E%3Crect x='8' y='18' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='22' y='12' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='14' y='20' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='3' y='23' width='4' height='3' fill='%23059669' opacity='0.5'/%3E%3Crect x='4' y='22' width='2' height='1' fill='%2310b981' opacity='0.4'/%3E%3Crect x='20' y='24' width='3' height='2' fill='%23059669' opacity='0.4'/%3E%3Crect x='12' y='25' width='3' height='2' fill='%23ec4899' opacity='0.45'/%3E%3Crect x='13' y='24' width='2' height='1' fill='%23f472b6' opacity='0.35'/%3E%3C/svg%3E") !important;
border: none !important;
border-radius: 0 !important;
width: 28px !important;
height: 28px !important;
color: white !important;
image-rendering: pixelated;
box-shadow: none !important;
transition: color 0.15s ease !important;
}
.content-area :deep(.send-btn:hover:not(:disabled)) {
color: #fffde0 !important;
filter: none !important;
box-shadow: 0 0 12px rgba(135, 206, 235, 0.5), 0 0 4px rgba(255, 253, 224, 0.3) !important;
}
.content-area :deep(.send-btn:disabled) {
opacity: 0.25 !important;
filter: saturate(0.3) !important;
}
/* Meta badges */
.content-area :deep(.meta-badge) {
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.4);
border-radius: 0;
font-family: 'Courier New', monospace;
}
.content-area :deep(.meta-badge.model) {
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
}
.content-area :deep(.meta-cwd),
.content-area :deep(.meta-duration),
.content-area :deep(.meta-count) {
color: rgba(255,255,255,0.25);
}
.content-area :deep(.copy-id-btn) {
color: rgba(255,255,255,0.25);
}
.content-area :deep(.copy-id-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.6);
}
/* Select mode */
.content-area :deep(.select-mode-btn) {
border-color: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.35);
border-radius: 0;
}
.content-area :deep(.select-mode-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.7);
}
.content-area :deep(.select-mode-btn.active) {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.35);
color: #c7d2fe;
}
/* Selection bar */
.content-area :deep(.selection-bar) {
background: rgba(8, 8, 12, 0.92);
border-color: rgba(255,255,255,0.06);
border-radius: 0;
}
.content-area :deep(.selection-count) {
color: rgba(255,255,255,0.4);
font-family: 'Courier New', monospace;
}
.content-area :deep(.selection-btn.toggle-all) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
border-radius: 0;
}
.content-area :deep(.message-wrapper.selected) {
background: rgba(99, 102, 241, 0.06);
}
</style>

View File

@@ -58,6 +58,11 @@ const router = createRouter({
path: '/transcript-debug',
name: 'transcript-debug',
component: () => import('../pages/TranscriptDebugPage.vue')
},
{
path: '/transcript-debug/:terminalIndex',
name: 'transcript-debug-terminal',
component: () => import('../pages/TranscriptDebugPage.vue')
}
]
})