feat: voice assistant integration, PiP window fixes, widget improvements and pixel art scrollbar
- Android voice assistant: RecognitionService, VoiceInteractionSession with startAssistantActivity, es-HN speech recognition - Voice transcript sent to first alive terminal via WebSocket, opens FloatingTranscriptDebug on correct session - PiP window: fix close button using getCurrentWebviewWindow(), add mini/restore toggle, remove alwaysOnTop - Add webview-close and window-destroy permissions to capabilities - Pixel art ocean scrollbar on /transcript-debug respecting scroll nav mode settings - Widget improvements: terminal list service, input widget provider, updated layouts
This commit is contained in:
@@ -32,6 +32,7 @@ const serverConfig = isTauri ? useServerConfig() : null
|
||||
const showServerConfig = ref(false)
|
||||
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
|
||||
|
||||
const isPipWindow = computed(() => route.query.pip === '1')
|
||||
const showVoice = ref(false)
|
||||
const showTranscriptDebug = ref(false)
|
||||
const showDebugConsole = ref(false)
|
||||
@@ -229,23 +230,16 @@ onMounted(async () => {
|
||||
return true
|
||||
}
|
||||
|
||||
// Bridge for Android voice assistant — opens FloatingTranscriptDebug on the target terminal
|
||||
// Bridge for Android voice assistant — navigates to transcript-debug page on the target terminal
|
||||
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
|
||||
const entry = sessionState.terminalRegistry.find(
|
||||
t => t.ephemeralSessionId === ephemeralSessionId
|
||||
)
|
||||
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
|
||||
const idx = entry
|
||||
? sessionState.terminalRegistry.indexOf(entry) + 1
|
||||
: 1
|
||||
router.push(`/transcript-debug/${idx}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// Sync Windows titlebar color with CSS variable
|
||||
@@ -372,8 +366,8 @@ if (serverConfig) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container" :class="{ wco: forceWco }">
|
||||
<header class="app-header" :class="{ 'wco-header': forceWco }">
|
||||
<div class="app-container" :class="{ wco: forceWco, 'pip-window': isPipWindow }">
|
||||
<header v-if="!isPipWindow" class="app-header" :class="{ 'wco-header': forceWco }">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="toolbar-toggle"
|
||||
@@ -439,7 +433,7 @@ if (serverConfig) {
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<Toolbar :collapsed="!toolbarVisible" />
|
||||
<Toolbar v-if="!isPipWindow" :collapsed="!toolbarVisible" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
@@ -447,8 +441,8 @@ if (serverConfig) {
|
||||
</RouterView>
|
||||
</main>
|
||||
|
||||
<!-- Transcript Debug FAB Button (pixel art ocean) -->
|
||||
<div class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
|
||||
<!-- Transcript Debug FAB Button (pixel art ocean) — hidden in PiP -->
|
||||
<div v-if="!isPipWindow" class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
|
||||
<TerminalFabStack
|
||||
:terminals="extraTerminals"
|
||||
:active-session-id="transcriptDebugRef?.activeTerminalSessionId ?? null"
|
||||
@@ -559,6 +553,16 @@ if (serverConfig) {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* PiP window: no header, no chrome, full bleed */
|
||||
.app-container.pip-window {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container.pip-window .app-main {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,66 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { isTauri, isMobileTauri, getTauriWindow } from '../lib/tauri'
|
||||
|
||||
const isDesktopTauri = isTauri && !isMobileTauri()
|
||||
// Always show on non-mobile — we have decorations:false so we need these
|
||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent)
|
||||
const show = !isMobile
|
||||
|
||||
const isMaximized = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
async function getWin() {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
return getCurrentWindow()
|
||||
}
|
||||
|
||||
async function initWindowState() {
|
||||
if (!isDesktopTauri) return
|
||||
try {
|
||||
const win = await getTauriWindow()
|
||||
const win = await getWin()
|
||||
isMaximized.value = await win.isMaximized()
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized.value = await win.isMaximized()
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[WindowControls] init failed:', e)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function minimizeWindow() {
|
||||
try {
|
||||
const win = await getTauriWindow()
|
||||
await win.minimize()
|
||||
} catch (e) {
|
||||
console.warn('[WindowControls] minimize failed:', e)
|
||||
}
|
||||
try { (await getWin()).minimize() } catch { }
|
||||
}
|
||||
|
||||
async function toggleMaximize() {
|
||||
try {
|
||||
const win = await getTauriWindow()
|
||||
await win.toggleMaximize()
|
||||
} catch (e) {
|
||||
console.warn('[WindowControls] toggleMaximize failed:', e)
|
||||
}
|
||||
try { (await getWin()).toggleMaximize() } catch { }
|
||||
}
|
||||
|
||||
async function closeWindow() {
|
||||
try {
|
||||
const win = await getTauriWindow()
|
||||
await win.close()
|
||||
} catch (e) {
|
||||
console.warn('[WindowControls] close failed:', e)
|
||||
(await getWin()).close()
|
||||
} catch {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initWindowState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
})
|
||||
onMounted(() => { if (show) initWindowState() })
|
||||
onUnmounted(() => { unlisten?.() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isDesktopTauri" class="window-controls">
|
||||
<div v-if="show" 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" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
import { isTauri, isMobileTauri, getTauriWindow } from '@/lib/tauri'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -59,6 +60,7 @@ const agents: { id: AgentName; label: string }[] = [
|
||||
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
|
||||
const showSelector = ref(false)
|
||||
const showNewSessionModal = ref(false)
|
||||
const isPipWindow = computed(() => route.query.pip === '1')
|
||||
|
||||
// Readability overlay
|
||||
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
||||
@@ -160,6 +162,84 @@ watch(() => sessionState.terminalRegistry.length, () => {
|
||||
if (route.params.terminalIndex) syncTerminalFromRoute()
|
||||
})
|
||||
|
||||
const isAndroid = isMobileTauri()
|
||||
const pipOpen = ref(false)
|
||||
|
||||
async function closePipWindow() {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
} catch {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
const pipMini = ref(false)
|
||||
async function togglePipMini() {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||
const win = getCurrentWindow()
|
||||
if (pipMini.value) {
|
||||
await win.setSize(new (await import('@tauri-apps/api/dpi')).LogicalSize(380, 620))
|
||||
pipMini.value = false
|
||||
} else {
|
||||
await win.setSize(new (await import('@tauri-apps/api/dpi')).LogicalSize(320, 180))
|
||||
pipMini.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function enterPip() {
|
||||
// Android: native PiP
|
||||
if (isAndroid) {
|
||||
const bridge = (window as any).AgentUI
|
||||
if (bridge?.enterPip) bridge.enterPip()
|
||||
return
|
||||
}
|
||||
|
||||
// Desktop Tauri: toggle pip window
|
||||
if (!isTauri) return
|
||||
if (pipOpen.value) {
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('pip-terminal')
|
||||
if (existing) await existing.close()
|
||||
} catch {}
|
||||
pipOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
|
||||
// Build the URL for the pip window — same transcript page with terminal index
|
||||
const terminalIdx = route.params.terminalIndex || '1'
|
||||
const pipUrl = `/transcript-debug/${terminalIdx}?pip=1`
|
||||
|
||||
const pip = new WebviewWindow('pip-terminal', {
|
||||
url: pipUrl,
|
||||
title: 'Agent UI',
|
||||
width: 380,
|
||||
height: 620,
|
||||
x: window.screen.width - 400,
|
||||
y: 60,
|
||||
alwaysOnTop: false,
|
||||
decorations: false,
|
||||
resizable: true,
|
||||
focus: true,
|
||||
})
|
||||
|
||||
pipOpen.value = true
|
||||
|
||||
pip.onCloseRequested(() => {
|
||||
pipOpen.value = false
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Failed to open PiP window:', e)
|
||||
pipOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await init()
|
||||
await voice.init()
|
||||
@@ -173,9 +253,29 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transcript-debug-page">
|
||||
<!-- Terminal selector strip -->
|
||||
<div class="terminal-strip">
|
||||
<div :class="['transcript-debug-page', { 'pip-mode': isPipWindow }]">
|
||||
<!-- PiP window title bar with drag + close -->
|
||||
<div v-if="isPipWindow" class="pip-titlebar">
|
||||
<span class="pip-title">Agent UI</span>
|
||||
<div class="pip-controls">
|
||||
<button class="pip-btn pip-mini" @click="togglePipMini" :title="pipMini ? 'Restore' : 'Mini'">
|
||||
<svg v-if="!pipMini" width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect x="0.5" y="4.5" width="9" height="5" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
</svg>
|
||||
<svg v-else 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>
|
||||
</button>
|
||||
<button class="pip-btn pip-close" @click="closePipWindow" 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.4" />
|
||||
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Terminal selector strip (hidden in PiP) -->
|
||||
<div v-if="!isPipWindow" class="terminal-strip">
|
||||
<div class="strip-left">
|
||||
<button
|
||||
v-for="(entry, idx) in sessionState.terminalRegistry"
|
||||
@@ -235,6 +335,17 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="isTauri"
|
||||
@click.stop="enterPip"
|
||||
:class="['strip-btn', 'pip-btn', { active: pipOpen }]"
|
||||
title="Picture-in-Picture"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<rect x="12" y="9" width="8" height="6" rx="1" fill="currentColor" opacity="0.3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click.stop="showSelector = !showSelector"
|
||||
:class="['strip-btn', { active: showSelector }]"
|
||||
@@ -252,7 +363,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div :class="['content-area', { 'selector-open': showSelector }]">
|
||||
<div :class="['content-area', { 'selector-open': showSelector }, `nav-${scrollNavMode}`]">
|
||||
<AquaticBackground />
|
||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||
|
||||
@@ -595,6 +706,42 @@ onBeforeUnmount(() => {
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
/* ── Scroll modes: hide scrollbar for buttons / none ── */
|
||||
.content-area:not(.nav-scrollbar) :deep(.messages-scroll) {
|
||||
scrollbar-width: none !important;
|
||||
}
|
||||
.content-area:not(.nav-scrollbar) :deep(.messages-scroll)::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ── Pixel art scrollbar (only in scrollbar mode) ── */
|
||||
.content-area.nav-scrollbar :deep(.messages-scroll) {
|
||||
scrollbar-gutter: stable !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(14, 165, 233, 0.3) rgba(12, 45, 74, 0.4);
|
||||
}
|
||||
|
||||
.content-area.nav-scrollbar :deep(.messages-scroll)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.content-area.nav-scrollbar :deep(.messages-scroll)::-webkit-scrollbar-track {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='8' fill='%230c2d4a' opacity='0.4'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23075985' opacity='0.15'/%3E%3Crect x='6' y='6' width='2' height='2' fill='%23075985' opacity='0.1'/%3E%3C/svg%3E") repeat;
|
||||
}
|
||||
|
||||
.content-area.nav-scrollbar :deep(.messages-scroll)::-webkit-scrollbar-thumb {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.3'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.15'/%3E%3C/svg%3E") repeat;
|
||||
border: 1px solid rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.content-area.nav-scrollbar :deep(.messages-scroll)::-webkit-scrollbar-thumb:hover {
|
||||
background:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.45'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.25'/%3E%3C/svg%3E") repeat;
|
||||
border-color: rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
|
||||
/* Bottom overlay: absolute container for lifecycle + input + status */
|
||||
.content-area :deep(.bottom-overlay) {
|
||||
position: absolute !important;
|
||||
@@ -608,6 +755,20 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(8px) !important;
|
||||
-webkit-backdrop-filter: blur(8px) !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||
/* Auto-hide: fade out when idle */
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Show on hover anywhere in the content area or when input is focused */
|
||||
.content-area:hover :deep(.bottom-overlay),
|
||||
.content-area:has(:focus-within) :deep(.bottom-overlay),
|
||||
.content-area :deep(.bottom-overlay:focus-within) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.content-area :deep(.status-bar) {
|
||||
@@ -761,4 +922,61 @@ onBeforeUnmount(() => {
|
||||
.content-area :deep(.message-wrapper.selected) {
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
/* PiP window titlebar */
|
||||
.pip-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: transparent;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pip-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pip-controls {
|
||||
display: flex;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.pip-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary, #a1a1aa);
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.pip-btn:hover {
|
||||
background: var(--bg-hover, #1e1e28);
|
||||
color: var(--text-primary, #e4e4e7);
|
||||
}
|
||||
|
||||
.pip-close:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user