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 showServerConfig = ref(false)
|
||||||
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
|
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
|
||||||
|
|
||||||
|
const isPipWindow = computed(() => route.query.pip === '1')
|
||||||
const showVoice = ref(false)
|
const showVoice = ref(false)
|
||||||
const showTranscriptDebug = ref(false)
|
const showTranscriptDebug = ref(false)
|
||||||
const showDebugConsole = ref(false)
|
const showDebugConsole = ref(false)
|
||||||
@@ -229,23 +230,16 @@ onMounted(async () => {
|
|||||||
return true
|
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) => {
|
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
|
||||||
const entry = sessionState.terminalRegistry.find(
|
const entry = sessionState.terminalRegistry.find(
|
||||||
t => t.ephemeralSessionId === ephemeralSessionId
|
t => t.ephemeralSessionId === ephemeralSessionId
|
||||||
)
|
)
|
||||||
if (entry && transcriptDebugRef.value) {
|
const idx = entry
|
||||||
transcriptDebugRef.value.switchToTerminal(entry.transcriptSessionId)
|
? sessionState.terminalRegistry.indexOf(entry) + 1
|
||||||
showTranscriptDebug.value = true
|
: 1
|
||||||
return true
|
router.push(`/transcript-debug/${idx}`)
|
||||||
}
|
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
|
// Sync Windows titlebar color with CSS variable
|
||||||
@@ -372,8 +366,8 @@ if (serverConfig) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-container" :class="{ wco: forceWco }">
|
<div class="app-container" :class="{ wco: forceWco, 'pip-window': isPipWindow }">
|
||||||
<header class="app-header" :class="{ 'wco-header': forceWco }">
|
<header v-if="!isPipWindow" class="app-header" :class="{ 'wco-header': forceWco }">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button
|
<button
|
||||||
class="toolbar-toggle"
|
class="toolbar-toggle"
|
||||||
@@ -439,7 +433,7 @@ if (serverConfig) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<Toolbar :collapsed="!toolbarVisible" />
|
<Toolbar v-if="!isPipWindow" :collapsed="!toolbarVisible" />
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<Transition name="page" mode="out-in">
|
<Transition name="page" mode="out-in">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
@@ -447,8 +441,8 @@ if (serverConfig) {
|
|||||||
</RouterView>
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Transcript Debug FAB Button (pixel art ocean) -->
|
<!-- Transcript Debug FAB Button (pixel art ocean) — hidden in PiP -->
|
||||||
<div class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
|
<div v-if="!isPipWindow" class="transcript-fab-wrap" :class="{ 'sheet-open': showVoice || showTranscriptDebug, 'keyboard-visible': keyboardVisible }" @contextmenu.prevent>
|
||||||
<TerminalFabStack
|
<TerminalFabStack
|
||||||
:terminals="extraTerminals"
|
:terminals="extraTerminals"
|
||||||
:active-session-id="transcriptDebugRef?.activeTerminalSessionId ?? null"
|
:active-session-id="transcriptDebugRef?.activeTerminalSessionId ?? null"
|
||||||
@@ -559,6 +553,16 @@ if (serverConfig) {
|
|||||||
background: var(--bg-primary);
|
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 {
|
.app-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,66 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
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)
|
const isMaximized = ref(false)
|
||||||
let unlisten: (() => void) | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
async function getWin() {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
return getCurrentWindow()
|
||||||
|
}
|
||||||
|
|
||||||
async function initWindowState() {
|
async function initWindowState() {
|
||||||
if (!isDesktopTauri) return
|
|
||||||
try {
|
try {
|
||||||
const win = await getTauriWindow()
|
const win = await getWin()
|
||||||
isMaximized.value = await win.isMaximized()
|
isMaximized.value = await win.isMaximized()
|
||||||
unlisten = await win.onResized(async () => {
|
unlisten = await win.onResized(async () => {
|
||||||
isMaximized.value = await win.isMaximized()
|
isMaximized.value = await win.isMaximized()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch {}
|
||||||
console.warn('[WindowControls] init failed:', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function minimizeWindow() {
|
async function minimizeWindow() {
|
||||||
try {
|
try { (await getWin()).minimize() } catch { }
|
||||||
const win = await getTauriWindow()
|
|
||||||
await win.minimize()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[WindowControls] minimize failed:', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMaximize() {
|
async function toggleMaximize() {
|
||||||
try {
|
try { (await getWin()).toggleMaximize() } catch { }
|
||||||
const win = await getTauriWindow()
|
|
||||||
await win.toggleMaximize()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[WindowControls] toggleMaximize failed:', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeWindow() {
|
async function closeWindow() {
|
||||||
try {
|
try {
|
||||||
const win = await getTauriWindow()
|
(await getWin()).close()
|
||||||
await win.close()
|
} catch {
|
||||||
} catch (e) {
|
window.close()
|
||||||
console.warn('[WindowControls] close failed:', e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => { if (show) initWindowState() })
|
||||||
initWindowState()
|
onUnmounted(() => { unlisten?.() })
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (unlisten) {
|
|
||||||
unlisten()
|
|
||||||
unlisten = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
|
||||||
<svg width="10" height="1" viewBox="0 0 10 1">
|
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||||
<rect width="10" height="1" fill="currentColor" />
|
<rect width="10" height="1" fill="currentColor" />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useVoiceInput } from '@/composables/useVoiceInput'
|
|||||||
import { useSessionState } from '@/stores/session-state'
|
import { useSessionState } from '@/stores/session-state'
|
||||||
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
||||||
import type { AgentName } from '@/types/transcript-debug'
|
import type { AgentName } from '@/types/transcript-debug'
|
||||||
|
import { isTauri, isMobileTauri, getTauriWindow } from '@/lib/tauri'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -59,6 +60,7 @@ const agents: { id: AgentName; label: string }[] = [
|
|||||||
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
|
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
|
||||||
const showSelector = ref(false)
|
const showSelector = ref(false)
|
||||||
const showNewSessionModal = ref(false)
|
const showNewSessionModal = ref(false)
|
||||||
|
const isPipWindow = computed(() => route.query.pip === '1')
|
||||||
|
|
||||||
// Readability overlay
|
// Readability overlay
|
||||||
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
||||||
@@ -160,6 +162,84 @@ watch(() => sessionState.terminalRegistry.length, () => {
|
|||||||
if (route.params.terminalIndex) syncTerminalFromRoute()
|
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 () => {
|
onMounted(async () => {
|
||||||
await init()
|
await init()
|
||||||
await voice.init()
|
await voice.init()
|
||||||
@@ -173,9 +253,29 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="transcript-debug-page">
|
<div :class="['transcript-debug-page', { 'pip-mode': isPipWindow }]">
|
||||||
<!-- Terminal selector strip -->
|
<!-- PiP window title bar with drag + close -->
|
||||||
<div class="terminal-strip">
|
<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">
|
<div class="strip-left">
|
||||||
<button
|
<button
|
||||||
v-for="(entry, idx) in sessionState.terminalRegistry"
|
v-for="(entry, idx) in sessionState.terminalRegistry"
|
||||||
@@ -235,6 +335,17 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
@click.stop="showSelector = !showSelector"
|
@click.stop="showSelector = !showSelector"
|
||||||
:class="['strip-btn', { active: showSelector }]"
|
:class="['strip-btn', { active: showSelector }]"
|
||||||
@@ -252,7 +363,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div :class="['content-area', { 'selector-open': showSelector }]">
|
<div :class="['content-area', { 'selector-open': showSelector }, `nav-${scrollNavMode}`]">
|
||||||
<AquaticBackground />
|
<AquaticBackground />
|
||||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||||
|
|
||||||
@@ -595,6 +706,42 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1 !important;
|
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 */
|
/* Bottom overlay: absolute container for lifecycle + input + status */
|
||||||
.content-area :deep(.bottom-overlay) {
|
.content-area :deep(.bottom-overlay) {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
@@ -608,6 +755,20 @@ onBeforeUnmount(() => {
|
|||||||
backdrop-filter: blur(8px) !important;
|
backdrop-filter: blur(8px) !important;
|
||||||
-webkit-backdrop-filter: blur(8px) !important;
|
-webkit-backdrop-filter: blur(8px) !important;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06) !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) {
|
.content-area :deep(.status-bar) {
|
||||||
@@ -761,4 +922,61 @@ onBeforeUnmount(() => {
|
|||||||
.content-area :deep(.message-wrapper.selected) {
|
.content-area :deep(.message-wrapper.selected) {
|
||||||
background: rgba(99, 102, 241, 0.06);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { PORT_TERMINAL } from '../config'
|
|||||||
* so external clients (Android widget) get everything in one call.
|
* so external clients (Android widget) get everything in one call.
|
||||||
*/
|
*/
|
||||||
export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [stateResp, registryResp] = await Promise.all([
|
const [stateResp, registryResp] = await Promise.all([
|
||||||
fetch(`http://localhost:${PORT_TERMINAL}/session-state`),
|
fetch(`http://localhost:${PORT_TERMINAL}/session-state`, { signal: controller.signal }),
|
||||||
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
|
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`, { signal: controller.signal })
|
||||||
])
|
])
|
||||||
|
|
||||||
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
|
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
|
||||||
@@ -21,6 +24,11 @@ export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
|||||||
registry: registryData.registry ?? []
|
registry: registryData.registry ?? []
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return errorResponse(`Failed to reach terminal server: ${e.message}`, 502)
|
const msg = e.name === 'AbortError'
|
||||||
|
? 'Terminal server timeout (6s)'
|
||||||
|
: `Failed to reach terminal server: ${e.message}`
|
||||||
|
return errorResponse(msg, 502)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default permissions for Agent UI",
|
"description": "Default permissions for Agent UI",
|
||||||
"windows": ["main"],
|
"windows": ["main", "pip-terminal"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
{
|
{
|
||||||
@@ -17,10 +17,20 @@
|
|||||||
"clipboard-manager:default",
|
"clipboard-manager:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
|
"core:window:allow-create",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-maximize",
|
"core:window:allow-maximize",
|
||||||
"core:window:allow-unmaximize",
|
"core:window:allow-unmaximize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close"
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-position",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-set-decorations",
|
||||||
|
"core:webview:default",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-webview-close",
|
||||||
|
"core:window:allow-destroy"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/Theme.agent_ui"
|
android:theme="@style/Theme.agent_ui"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
@@ -37,6 +38,21 @@
|
|||||||
android:resource="@xml/transcript_widget_info" />
|
android:resource="@xml/transcript_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- Input Widget -->
|
||||||
|
<receiver
|
||||||
|
android:name=".InputWidgetProvider"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
<action android:name="com.agentui.desktop.INPUT_WIDGET_SEND" />
|
||||||
|
<action android:name="com.agentui.desktop.INPUT_WIDGET_MIC" />
|
||||||
|
<action android:name="com.agentui.desktop.INPUT_WIDGET_CANCEL" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/input_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Voice Command / Share / Assist Activity -->
|
<!-- Voice Command / Share / Assist Activity -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".VoiceCommandActivity"
|
android:name=".VoiceCommandActivity"
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
|
||||||
|
class InputWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "InputWidget"
|
||||||
|
|
||||||
|
const val ACTION_SEND = "com.agentui.desktop.INPUT_WIDGET_SEND"
|
||||||
|
const val ACTION_MIC = "com.agentui.desktop.INPUT_WIDGET_MIC"
|
||||||
|
const val ACTION_CANCEL = "com.agentui.desktop.INPUT_WIDGET_CANCEL"
|
||||||
|
|
||||||
|
private const val STATE_IDLE = 0
|
||||||
|
private const val STATE_SENDING = 1
|
||||||
|
private const val STATE_PROCESSING = 2
|
||||||
|
private const val STATE_DONE = 3
|
||||||
|
private const val STATE_ERROR = 4
|
||||||
|
|
||||||
|
private const val PREFS_NAME = "input_widget_prefs"
|
||||||
|
private const val KEY_STATE = "hook_state"
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
private const val COLOR_IDLE = 0x8040C040.toInt()
|
||||||
|
private const val COLOR_SENDING = 0xFF60a5fa.toInt()
|
||||||
|
private const val COLOR_PROCESSING = 0xFFfbbf24.toInt()
|
||||||
|
private const val COLOR_DONE = 0xFF4ade80.toInt()
|
||||||
|
private const val COLOR_ERROR = 0xFFf87171.toInt()
|
||||||
|
|
||||||
|
private const val COLOR_BTN_ACTIVE = 0xEEFFFFFF.toInt()
|
||||||
|
private const val COLOR_BTN_NORMAL = 0xAAFFFFFF.toInt()
|
||||||
|
private const val COLOR_BTN_DIM = 0x50FFFFFF.toInt()
|
||||||
|
private const val COLOR_TEXT_DIM = 0x80FFFFFF.toInt()
|
||||||
|
|
||||||
|
private val STATUS_LABELS = mapOf(
|
||||||
|
STATE_IDLE to "idle",
|
||||||
|
STATE_SENDING to "sending...",
|
||||||
|
STATE_PROCESSING to "processing...",
|
||||||
|
STATE_DONE to "done",
|
||||||
|
STATE_ERROR to "error"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "onUpdate called for ${appWidgetIds.size} widgets")
|
||||||
|
for (id in appWidgetIds) {
|
||||||
|
updateWidget(context, appWidgetManager, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
super.onReceive(context, intent)
|
||||||
|
|
||||||
|
Log.d(TAG, "onReceive: action=${intent.action}")
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_SEND -> {
|
||||||
|
val next = when (getState(context)) {
|
||||||
|
STATE_IDLE -> STATE_SENDING
|
||||||
|
STATE_SENDING -> STATE_PROCESSING
|
||||||
|
STATE_PROCESSING -> STATE_DONE
|
||||||
|
else -> STATE_IDLE
|
||||||
|
}
|
||||||
|
Log.d(TAG, "SEND: state -> $next")
|
||||||
|
setState(context, next)
|
||||||
|
refreshAll(context)
|
||||||
|
}
|
||||||
|
ACTION_MIC -> {
|
||||||
|
val next = if (getState(context) == STATE_IDLE) STATE_SENDING else STATE_IDLE
|
||||||
|
Log.d(TAG, "MIC: state -> $next")
|
||||||
|
setState(context, next)
|
||||||
|
refreshAll(context)
|
||||||
|
}
|
||||||
|
ACTION_CANCEL -> {
|
||||||
|
val current = getState(context)
|
||||||
|
val next = when (current) {
|
||||||
|
STATE_SENDING, STATE_PROCESSING -> STATE_ERROR
|
||||||
|
else -> STATE_IDLE
|
||||||
|
}
|
||||||
|
Log.d(TAG, "CANCEL: state -> $next")
|
||||||
|
setState(context, next)
|
||||||
|
refreshAll(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWidget(context: Context, mgr: AppWidgetManager, id: Int) {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.widget_input)
|
||||||
|
val state = getState(context)
|
||||||
|
|
||||||
|
// Open app — only the ↗ button
|
||||||
|
val appIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
val appPending = PendingIntent.getActivity(
|
||||||
|
context, 100, appIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.btn_open_app, appPending)
|
||||||
|
|
||||||
|
// Input field → send
|
||||||
|
views.setOnClickPendingIntent(R.id.input_field,
|
||||||
|
makeBroadcast(context, ACTION_SEND, "input_field"))
|
||||||
|
|
||||||
|
// Buttons — each with unique data URI to avoid PendingIntent deduplication
|
||||||
|
views.setOnClickPendingIntent(R.id.btn_send,
|
||||||
|
makeBroadcast(context, ACTION_SEND, "btn_send"))
|
||||||
|
views.setOnClickPendingIntent(R.id.btn_mic,
|
||||||
|
makeBroadcast(context, ACTION_MIC, "btn_mic"))
|
||||||
|
views.setOnClickPendingIntent(R.id.btn_cancel,
|
||||||
|
makeBroadcast(context, ACTION_CANCEL, "btn_cancel"))
|
||||||
|
|
||||||
|
applyState(views, state)
|
||||||
|
mgr.updateAppWidget(id, views)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique PendingIntent per button using data URI differentiation.
|
||||||
|
* This prevents Android from deduplicating PendingIntents that share the same action.
|
||||||
|
*/
|
||||||
|
private fun makeBroadcast(context: Context, action: String, tag: String): PendingIntent {
|
||||||
|
val intent = Intent(action).apply {
|
||||||
|
component = ComponentName(context, InputWidgetProvider::class.java)
|
||||||
|
data = Uri.parse("agentui://input-widget/$tag")
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(
|
||||||
|
context, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyState(views: RemoteViews, state: Int) {
|
||||||
|
val stateColor = when (state) {
|
||||||
|
STATE_IDLE -> COLOR_IDLE
|
||||||
|
STATE_SENDING -> COLOR_SENDING
|
||||||
|
STATE_PROCESSING -> COLOR_PROCESSING
|
||||||
|
STATE_DONE -> COLOR_DONE
|
||||||
|
STATE_ERROR -> COLOR_ERROR
|
||||||
|
else -> COLOR_IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status dot
|
||||||
|
val dotGlyph = when (state) {
|
||||||
|
STATE_SENDING, STATE_PROCESSING -> "\u25CC"
|
||||||
|
else -> "\u25CF"
|
||||||
|
}
|
||||||
|
views.setTextViewText(R.id.input_status_dot, dotGlyph)
|
||||||
|
views.setTextColor(R.id.input_status_dot, stateColor)
|
||||||
|
|
||||||
|
// Status text
|
||||||
|
views.setTextViewText(R.id.input_status_text, STATUS_LABELS[state] ?: "idle")
|
||||||
|
views.setTextColor(R.id.input_status_text, stateColor)
|
||||||
|
|
||||||
|
// Input field prompt
|
||||||
|
val prompt = when (state) {
|
||||||
|
STATE_IDLE -> "$ _"
|
||||||
|
STATE_SENDING -> "$ sending..."
|
||||||
|
STATE_PROCESSING -> "$ processing..."
|
||||||
|
STATE_DONE -> "$ done \u2713"
|
||||||
|
STATE_ERROR -> "$ error \u26A0"
|
||||||
|
else -> "$ _"
|
||||||
|
}
|
||||||
|
views.setTextViewText(R.id.input_field, prompt)
|
||||||
|
views.setTextColor(R.id.input_field, when (state) {
|
||||||
|
STATE_IDLE -> COLOR_TEXT_DIM
|
||||||
|
else -> stateColor
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send button
|
||||||
|
views.setTextColor(R.id.btn_send, when (state) {
|
||||||
|
STATE_IDLE -> COLOR_BTN_ACTIVE
|
||||||
|
STATE_DONE, STATE_ERROR -> COLOR_BTN_NORMAL
|
||||||
|
else -> COLOR_BTN_DIM
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mic button
|
||||||
|
views.setTextColor(R.id.btn_mic, when (state) {
|
||||||
|
STATE_IDLE -> COLOR_BTN_NORMAL
|
||||||
|
STATE_SENDING -> COLOR_SENDING
|
||||||
|
else -> COLOR_BTN_DIM
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
views.setTextColor(R.id.btn_cancel, when (state) {
|
||||||
|
STATE_SENDING, STATE_PROCESSING -> COLOR_ERROR
|
||||||
|
else -> COLOR_BTN_DIM
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getState(context: Context): Int =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getInt(KEY_STATE, STATE_IDLE)
|
||||||
|
|
||||||
|
private fun setState(context: Context, state: Int) {
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.edit().putInt(KEY_STATE, state).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAll(context: Context) {
|
||||||
|
val mgr = AppWidgetManager.getInstance(context)
|
||||||
|
val ids = mgr.getAppWidgetIds(ComponentName(context, InputWidgetProvider::class.java))
|
||||||
|
Log.d(TAG, "refreshAll: ${ids.size} widgets")
|
||||||
|
for (id in ids) updateWidget(context, mgr, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
@@ -21,6 +22,7 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "AgentUI"
|
||||||
private const val ACTION_PIP_MIC = "com.agentui.desktop.PIP_MIC"
|
private const val ACTION_PIP_MIC = "com.agentui.desktop.PIP_MIC"
|
||||||
private const val ACTION_PIP_EXPAND = "com.agentui.desktop.PIP_EXPAND"
|
private const val ACTION_PIP_EXPAND = "com.agentui.desktop.PIP_EXPAND"
|
||||||
private const val PIP_REQUEST_CODE_MIC = 2001
|
private const val PIP_REQUEST_CODE_MIC = 2001
|
||||||
@@ -34,8 +36,7 @@ class MainActivity : TauriActivity() {
|
|||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_PIP_MIC -> {
|
ACTION_PIP_MIC -> {
|
||||||
Log.d("AgentUI", "PiP mic button pressed")
|
Log.d(TAG, "PiP mic button pressed")
|
||||||
// Launch voice command from PiP
|
|
||||||
val voiceIntent = Intent(context, VoiceCommandActivity::class.java).apply {
|
val voiceIntent = Intent(context, VoiceCommandActivity::class.java).apply {
|
||||||
action = Intent.ACTION_VOICE_COMMAND
|
action = Intent.ACTION_VOICE_COMMAND
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
@@ -43,8 +44,7 @@ class MainActivity : TauriActivity() {
|
|||||||
startActivity(voiceIntent)
|
startActivity(voiceIntent)
|
||||||
}
|
}
|
||||||
ACTION_PIP_EXPAND -> {
|
ACTION_PIP_EXPAND -> {
|
||||||
Log.d("AgentUI", "PiP expand button pressed")
|
Log.d(TAG, "PiP expand button pressed")
|
||||||
// Bring app to foreground full-screen
|
|
||||||
val expandIntent = Intent(context, MainActivity::class.java).apply {
|
val expandIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,7 @@ class MainActivity : TauriActivity() {
|
|||||||
handleWidgetIntent(intent)
|
handleWidgetIntent(intent)
|
||||||
handleVoiceIntent(intent)
|
handleVoiceIntent(intent)
|
||||||
registerPipReceiver()
|
registerPipReceiver()
|
||||||
|
injectJsBridge()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@@ -69,84 +70,6 @@ class MainActivity : TauriActivity() {
|
|||||||
try { unregisterReceiver(pipReceiver) } catch (_: Exception) {}
|
try { unregisterReceiver(pipReceiver) } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerPipReceiver() {
|
|
||||||
val filter = IntentFilter().apply {
|
|
||||||
addAction(ACTION_PIP_MIC)
|
|
||||||
addAction(ACTION_PIP_EXPAND)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
registerReceiver(pipReceiver, filter, RECEIVER_NOT_EXPORTED)
|
|
||||||
} else {
|
|
||||||
registerReceiver(pipReceiver, filter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter PiP when user leaves the app (home button / swipe).
|
|
||||||
*/
|
|
||||||
override fun onUserLeaveHint() {
|
|
||||||
super.onUserLeaveHint()
|
|
||||||
// Only enter PiP via voice command flow, not on regular minimize
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPictureInPictureModeChanged(
|
|
||||||
isInPictureInPictureMode: Boolean,
|
|
||||||
newConfig: Configuration
|
|
||||||
) {
|
|
||||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
|
||||||
Log.d("AgentUI", "PiP mode changed: $isInPictureInPictureMode")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enterPipIfSupported() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
try {
|
|
||||||
val actions = buildPipActions()
|
|
||||||
val builder = PictureInPictureParams.Builder()
|
|
||||||
.setAspectRatio(Rational(9, 16))
|
|
||||||
.setActions(actions)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
builder.setAutoEnterEnabled(false)
|
|
||||||
builder.setSeamlessResizeEnabled(true)
|
|
||||||
}
|
|
||||||
enterPictureInPictureMode(builder.build())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w("AgentUI", "Failed to enter PiP: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPipActions(): List<RemoteAction> {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
|
|
||||||
|
|
||||||
val actions = mutableListOf<RemoteAction>()
|
|
||||||
|
|
||||||
// Mic button — launch voice command
|
|
||||||
val micIntent = PendingIntent.getBroadcast(
|
|
||||||
this, PIP_REQUEST_CODE_MIC,
|
|
||||||
Intent(ACTION_PIP_MIC).setPackage(packageName),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
actions.add(RemoteAction(
|
|
||||||
Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now),
|
|
||||||
"Voice", "Send voice command",
|
|
||||||
micIntent
|
|
||||||
))
|
|
||||||
|
|
||||||
// Expand button — bring app to foreground
|
|
||||||
val expandIntent = PendingIntent.getBroadcast(
|
|
||||||
this, PIP_REQUEST_CODE_EXPAND,
|
|
||||||
Intent(ACTION_PIP_EXPAND).setPackage(packageName),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
actions.add(RemoteAction(
|
|
||||||
Icon.createWithResource(this, android.R.drawable.ic_menu_view),
|
|
||||||
"Open", "Open full app",
|
|
||||||
expandIntent
|
|
||||||
))
|
|
||||||
|
|
||||||
return actions
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: android.content.Intent) {
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleWidgetIntent(intent)
|
handleWidgetIntent(intent)
|
||||||
@@ -162,36 +85,121 @@ class MainActivity : TauriActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleWidgetIntent(intent: android.content.Intent?) {
|
override fun onPictureInPictureModeChanged(
|
||||||
val terminalIndex = intent?.getIntExtra("terminalIndex", -1) ?: -1
|
isInPictureInPictureMode: Boolean,
|
||||||
if (terminalIndex > 0) {
|
newConfig: Configuration
|
||||||
val route = "/transcript-debug/$terminalIndex"
|
) {
|
||||||
Log.d("AgentUI", "Widget click → navigate to $route")
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
pendingRoute = route
|
Log.d(TAG, "PiP mode changed: $isInPictureInPictureMode")
|
||||||
navigateWebView(route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleVoiceIntent(intent: android.content.Intent?) {
|
// ── PiP ──
|
||||||
if (intent?.action == "com.agentui.desktop.VOICE_TERMINAL") {
|
|
||||||
val sessionId = intent.getStringExtra("ephemeralSessionId") ?: return
|
private fun enterPipIfSupported() {
|
||||||
if (sessionId.isNotEmpty()) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
Log.d("AgentUI", "Voice intent → open terminal $sessionId")
|
try {
|
||||||
pendingVoiceTerminal = sessionId
|
val actions = buildPipActions()
|
||||||
// Don't call openVoiceTerminal here — WebView may not exist yet.
|
val builder = PictureInPictureParams.Builder()
|
||||||
// Instead, poll until the WebView is ready.
|
.setAspectRatio(Rational(9, 16))
|
||||||
pollForWebViewAndOpenTerminal(sessionId, 0)
|
.setActions(actions)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
builder.setSeamlessResizeEnabled(true)
|
||||||
|
}
|
||||||
|
enterPictureInPictureMode(builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to enter PiP: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildPipActions(): List<RemoteAction> {
|
||||||
* Retry finding the WebView up to ~3 seconds (15 attempts x 200ms).
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
|
||||||
* Once found, inject JS to open floating transcript and enter PiP.
|
|
||||||
*/
|
val actions = mutableListOf<RemoteAction>()
|
||||||
private fun pollForWebViewAndOpenTerminal(ephemeralSessionId: String, attempt: Int) {
|
|
||||||
|
val micIntent = PendingIntent.getBroadcast(
|
||||||
|
this, PIP_REQUEST_CODE_MIC,
|
||||||
|
Intent(ACTION_PIP_MIC).setPackage(packageName),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
actions.add(RemoteAction(
|
||||||
|
Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now),
|
||||||
|
"Voice", "Send voice command",
|
||||||
|
micIntent
|
||||||
|
))
|
||||||
|
|
||||||
|
val expandIntent = PendingIntent.getBroadcast(
|
||||||
|
this, PIP_REQUEST_CODE_EXPAND,
|
||||||
|
Intent(ACTION_PIP_EXPAND).setPackage(packageName),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
actions.add(RemoteAction(
|
||||||
|
Icon.createWithResource(this, android.R.drawable.ic_menu_view),
|
||||||
|
"Open", "Open full app",
|
||||||
|
expandIntent
|
||||||
|
))
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPipReceiver() {
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(ACTION_PIP_MIC)
|
||||||
|
addAction(ACTION_PIP_EXPAND)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerReceiver(pipReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
registerReceiver(pipReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JS Bridge (exposes AgentUI.enterPip() to the WebView) ──
|
||||||
|
|
||||||
|
private fun injectJsBridge() {
|
||||||
|
window.decorView.postDelayed({
|
||||||
|
try {
|
||||||
|
val webView = findWebView(window.decorView)
|
||||||
|
if (webView != null) {
|
||||||
|
webView.addJavascriptInterface(JsBridge(), "AgentUI")
|
||||||
|
Log.d(TAG, "JS bridge injected (AgentUI.enterPip)")
|
||||||
|
} else {
|
||||||
|
// Retry once more after delay
|
||||||
|
window.decorView.postDelayed({
|
||||||
|
val wv = findWebView(window.decorView)
|
||||||
|
if (wv != null) {
|
||||||
|
wv.addJavascriptInterface(JsBridge(), "AgentUI")
|
||||||
|
Log.d(TAG, "JS bridge injected on retry")
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to inject JS bridge: $e")
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class JsBridge {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun enterPip() {
|
||||||
|
runOnUiThread { enterPipIfSupported() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Voice intent → navigate to transcript page ──
|
||||||
|
|
||||||
|
private fun handleVoiceIntent(intent: android.content.Intent?) {
|
||||||
|
if (intent?.action == "com.agentui.desktop.VOICE_TERMINAL") {
|
||||||
|
val sessionId = intent.getStringExtra("ephemeralSessionId") ?: ""
|
||||||
|
Log.d(TAG, "Voice intent → navigate to transcript-debug, terminal=$sessionId")
|
||||||
|
pendingVoiceTerminal = sessionId
|
||||||
|
pollForWebViewAndNavigate(sessionId, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pollForWebViewAndNavigate(ephemeralSessionId: String, attempt: Int) {
|
||||||
if (attempt > 15) {
|
if (attempt > 15) {
|
||||||
Log.w("AgentUI", "Gave up waiting for WebView after ${attempt} attempts")
|
Log.w(TAG, "Gave up waiting for WebView after $attempt attempts")
|
||||||
pendingVoiceTerminal = null
|
pendingVoiceTerminal = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -199,32 +207,43 @@ class MainActivity : TauriActivity() {
|
|||||||
val webView = try { findWebView(window.decorView) } catch (_: Exception) { null }
|
val webView = try { findWebView(window.decorView) } catch (_: Exception) { null }
|
||||||
|
|
||||||
if (webView != null) {
|
if (webView != null) {
|
||||||
|
// Navigate to transcript-debug page and tell it which terminal to focus
|
||||||
val js = "window.__VOICE_OPEN_TERMINAL__ && window.__VOICE_OPEN_TERMINAL__('$ephemeralSessionId')"
|
val js = "window.__VOICE_OPEN_TERMINAL__ && window.__VOICE_OPEN_TERMINAL__('$ephemeralSessionId')"
|
||||||
webView.evaluateJavascript(js, null)
|
webView.evaluateJavascript(js, null)
|
||||||
pendingVoiceTerminal = null
|
pendingVoiceTerminal = null
|
||||||
Log.d("AgentUI", "Voice terminal JS dispatched (attempt $attempt): $ephemeralSessionId")
|
Log.d(TAG, "Voice navigate dispatched (attempt $attempt): $ephemeralSessionId")
|
||||||
|
|
||||||
// Enter PiP after the WebView has time to render
|
|
||||||
webView.postDelayed({ enterPipIfSupported() }, 500)
|
|
||||||
} else {
|
} else {
|
||||||
Log.d("AgentUI", "WebView not ready (attempt $attempt), retrying in 200ms")
|
Log.d(TAG, "WebView not ready (attempt $attempt), retrying in 200ms")
|
||||||
window.decorView.postDelayed({
|
window.decorView.postDelayed({
|
||||||
pollForWebViewAndOpenTerminal(ephemeralSessionId, attempt + 1)
|
pollForWebViewAndNavigate(ephemeralSessionId, attempt + 1)
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Widget intent ──
|
||||||
|
|
||||||
|
private fun handleWidgetIntent(intent: android.content.Intent?) {
|
||||||
|
val terminalIndex = intent?.getIntExtra("terminalIndex", -1) ?: -1
|
||||||
|
if (terminalIndex > 0) {
|
||||||
|
val route = "/transcript-debug/$terminalIndex"
|
||||||
|
Log.d(TAG, "Widget click → navigate to $route")
|
||||||
|
pendingRoute = route
|
||||||
|
navigateWebView(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebView helpers ──
|
||||||
|
|
||||||
private fun navigateWebView(route: String) {
|
private fun navigateWebView(route: String) {
|
||||||
try {
|
try {
|
||||||
val decorView = window.decorView
|
val webView = findWebView(window.decorView)
|
||||||
val webView = findWebView(decorView)
|
|
||||||
if (webView != null) {
|
if (webView != null) {
|
||||||
val js = "window.__WIDGET_NAVIGATE__ && window.__WIDGET_NAVIGATE__('$route') || (window.location.href = '$route')"
|
val js = "window.__WIDGET_NAVIGATE__ && window.__WIDGET_NAVIGATE__('$route') || (window.location.href = '$route')"
|
||||||
webView.evaluateJavascript(js, null)
|
webView.evaluateJavascript(js, null)
|
||||||
pendingRoute = null
|
pendingRoute = null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("AgentUI", "Failed to navigate WebView: $e")
|
Log.w(TAG, "Failed to navigate WebView: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,14 +252,10 @@ class MainActivity : TauriActivity() {
|
|||||||
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
||||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
val density = resources.displayMetrics.density
|
val density = resources.displayMetrics.density
|
||||||
val topPx = systemBars.top
|
val topDp = systemBars.top / density
|
||||||
val bottomPx = systemBars.bottom
|
val bottomDp = systemBars.bottom / density
|
||||||
val leftPx = systemBars.left
|
val leftDp = systemBars.left / density
|
||||||
val rightPx = systemBars.right
|
val rightDp = systemBars.right / density
|
||||||
val topDp = topPx / density
|
|
||||||
val bottomDp = bottomPx / density
|
|
||||||
val leftDp = leftPx / density
|
|
||||||
val rightDp = rightPx / density
|
|
||||||
val js = """
|
val js = """
|
||||||
document.documentElement.style.setProperty('--sat', '${topDp}px');
|
document.documentElement.style.setProperty('--sat', '${topDp}px');
|
||||||
document.documentElement.style.setProperty('--sab', '${bottomDp}px');
|
document.documentElement.style.setProperty('--sab', '${bottomDp}px');
|
||||||
@@ -249,13 +264,11 @@ class MainActivity : TauriActivity() {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
try {
|
try {
|
||||||
val webView = view.findViewWithTag<WebView>("tauri_webview")
|
val webView = view.findViewWithTag<WebView>("tauri_webview")
|
||||||
?: view.findViewById<WebView>(android.R.id.content)?.let {
|
?: view.findViewById<WebView>(android.R.id.content)?.let { findWebView(it) }
|
||||||
findWebView(it)
|
|
||||||
}
|
|
||||||
webView?.evaluateJavascript(js, null)
|
webView?.evaluateJavascript(js, null)
|
||||||
Log.d("AgentUI", "Injected safe-area: top=${topDp}dp bottom=${bottomDp}dp")
|
Log.d(TAG, "Injected safe-area: top=${topDp}dp bottom=${bottomDp}dp")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w("AgentUI", "Failed to inject safe-area insets: $e")
|
Log.w(TAG, "Failed to inject safe-area insets: $e")
|
||||||
}
|
}
|
||||||
ViewCompat.onApplyWindowInsets(view, insets)
|
ViewCompat.onApplyWindowInsets(view, insets)
|
||||||
}
|
}
|
||||||
@@ -274,6 +287,6 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
private fun syncServerUrlToPrefs() {
|
private fun syncServerUrlToPrefs() {
|
||||||
val url = ServerConfig.getServerUrl(this)
|
val url = ServerConfig.getServerUrl(this)
|
||||||
Log.d("AgentUI", "syncServerUrlToPrefs: resolved url=$url")
|
Log.d(TAG, "syncServerUrlToPrefs: resolved url=$url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,14 +43,33 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
private const val ICON_OK = "\u2713" // ✓
|
private const val ICON_OK = "\u2713" // ✓
|
||||||
private const val ICON_ERROR = "\u26A0" // ⚠
|
private const val ICON_ERROR = "\u26A0" // ⚠
|
||||||
|
|
||||||
private const val COLOR_NORMAL = 0xFF8888FF.toInt()
|
private const val COLOR_NORMAL = 0xAAFFFFFF.toInt()
|
||||||
private const val COLOR_LOADING = 0xFF60a5fa.toInt()
|
private const val COLOR_LOADING = 0xFF60a5fa.toInt()
|
||||||
private const val COLOR_OK = 0xFF4ade80.toInt()
|
private const val COLOR_OK = 0xFF4ade80.toInt()
|
||||||
private const val COLOR_ERROR = 0xFFf87171.toInt()
|
private const val COLOR_ERROR = 0xFFf87171.toInt()
|
||||||
|
|
||||||
|
// Terminal-style status labels shown after agent name
|
||||||
|
private val STATUS_LABEL = mapOf(
|
||||||
|
"idle" to "idle",
|
||||||
|
"thinking" to "thinking...",
|
||||||
|
"reading" to "reading",
|
||||||
|
"writing" to "writing",
|
||||||
|
"toolUse" to "exec",
|
||||||
|
"permissionRequest" to "await",
|
||||||
|
"interrupted" to "SIGINT",
|
||||||
|
"error" to "ERR",
|
||||||
|
"sessionStart" to "init",
|
||||||
|
"sessionEnd" to "exit",
|
||||||
|
"closed" to "exit 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MAX_RETRIES = 3
|
||||||
|
private const val RETRY_DELAY_MS = 1500L
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(8, TimeUnit.SECONDS)
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
.readTimeout(8, TimeUnit.SECONDS)
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val STATUS_COLORS = mapOf(
|
private val STATUS_COLORS = mapOf(
|
||||||
@@ -73,10 +92,15 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
|
|
||||||
private var items = listOf<TerminalItem>()
|
private var items = listOf<TerminalItem>()
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var pendingReset: Runnable? = null
|
||||||
|
|
||||||
override fun onCreate() {}
|
override fun onCreate() {}
|
||||||
|
|
||||||
override fun onDataSetChanged() {
|
override fun onDataSetChanged() {
|
||||||
|
// Cancel any pending reset from a previous refresh cycle
|
||||||
|
pendingReset?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
pendingReset = null
|
||||||
|
|
||||||
setRefreshButton(ICON_LOADING, COLOR_LOADING)
|
setRefreshButton(ICON_LOADING, COLOR_LOADING)
|
||||||
|
|
||||||
val result = fetchTerminals()
|
val result = fetchTerminals()
|
||||||
@@ -105,15 +129,25 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
if (position >= items.size) return views
|
if (position >= items.size) return views
|
||||||
val item = items[position]
|
val item = items[position]
|
||||||
|
|
||||||
|
// Status dot color
|
||||||
views.setTextColor(R.id.item_dot, item.statusColor)
|
views.setTextColor(R.id.item_dot, item.statusColor)
|
||||||
|
|
||||||
|
// Terminal-style: "$ agent [status]"
|
||||||
val statusLabel = if (item.alive) item.status else "closed"
|
val statusLabel = if (item.alive) item.status else "closed"
|
||||||
views.setTextViewText(R.id.item_name, "T${item.terminalIndex} ${item.agent} $statusLabel")
|
val termLabel = STATUS_LABEL[statusLabel] ?: statusLabel
|
||||||
|
views.setTextViewText(
|
||||||
|
R.id.item_name,
|
||||||
|
"$ ${item.agent} [$termLabel]"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Badges (compact)
|
||||||
views.setTextViewText(R.id.item_badges, item.hookBadges)
|
views.setTextViewText(R.id.item_badges, item.hookBadges)
|
||||||
|
|
||||||
// Always show the registry label (unique per terminal)
|
// Combine label + lastUserPrompt to show maximum content
|
||||||
views.setTextViewText(R.id.item_label, item.label)
|
val parts = mutableListOf<String>()
|
||||||
|
if (item.label.isNotEmpty()) parts.add("> ${item.label}")
|
||||||
|
if (item.lastUserPrompt.isNotEmpty()) parts.add("$ ${item.lastUserPrompt}")
|
||||||
|
views.setTextViewText(R.id.item_label, parts.joinToString("\n"))
|
||||||
|
|
||||||
val fillIntent = Intent().apply {
|
val fillIntent = Intent().apply {
|
||||||
putExtra("terminalIndex", item.terminalIndex)
|
putExtra("terminalIndex", item.terminalIndex)
|
||||||
@@ -141,6 +175,16 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
views.setTextViewText(R.id.btn_refresh, icon)
|
views.setTextViewText(R.id.btn_refresh, icon)
|
||||||
views.setTextColor(R.id.btn_refresh, color)
|
views.setTextColor(R.id.btn_refresh, color)
|
||||||
|
|
||||||
|
// Update status dot indicator
|
||||||
|
val statusDot = when (color) {
|
||||||
|
COLOR_LOADING -> "◌"
|
||||||
|
COLOR_OK -> "●"
|
||||||
|
COLOR_ERROR -> "●"
|
||||||
|
else -> "●"
|
||||||
|
}
|
||||||
|
views.setTextViewText(R.id.widget_status_bar, statusDot)
|
||||||
|
views.setTextColor(R.id.widget_status_bar, color)
|
||||||
|
|
||||||
val mgr = AppWidgetManager.getInstance(context)
|
val mgr = AppWidgetManager.getInstance(context)
|
||||||
val ids = mgr.getAppWidgetIds(
|
val ids = mgr.getAppWidgetIds(
|
||||||
ComponentName(context, TranscriptWidgetProvider::class.java)
|
ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||||
@@ -153,34 +197,73 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule resetting the button back to normal after a delay.
|
* Schedule resetting the button back to normal after a delay.
|
||||||
|
* Cancels any pending reset to prevent flickering from overlapping refreshes.
|
||||||
*/
|
*/
|
||||||
private fun scheduleResetButton(delayMs: Long) {
|
private fun scheduleResetButton(delayMs: Long) {
|
||||||
mainHandler.postDelayed({
|
pendingReset?.let { mainHandler.removeCallbacks(it) }
|
||||||
setRefreshButton(ICON_NORMAL, COLOR_NORMAL)
|
val runnable = Runnable { setRefreshButton(ICON_NORMAL, COLOR_NORMAL) }
|
||||||
}, delayMs)
|
pendingReset = runnable
|
||||||
|
mainHandler.postDelayed(runnable, delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data fetching ──
|
// ── Data fetching ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns list on success, null on error.
|
* Returns list on success, null on error.
|
||||||
|
* Retries up to MAX_RETRIES on transient failures.
|
||||||
* Items keep the same order as the terminal registry (T1, T2, T3...)
|
* Items keep the same order as the terminal registry (T1, T2, T3...)
|
||||||
* so the index maps directly to /transcript-debug/:terminalIndex
|
* so the index maps directly to /transcript-debug/:terminalIndex
|
||||||
*/
|
*/
|
||||||
private fun fetchTerminals(): List<TerminalItem>? {
|
private fun fetchTerminals(): List<TerminalItem>? {
|
||||||
val apiBase = ServerConfig.apiBaseUrl(context) ?: return emptyList()
|
val apiBase = ServerConfig.apiBaseUrl(context)
|
||||||
|
if (apiBase == null) {
|
||||||
|
Log.w(TAG, "No server URL configured")
|
||||||
|
return null // Show error, not empty success
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
var lastException: Exception? = null
|
||||||
val url = "$apiBase/session-state"
|
|
||||||
val req = Request.Builder().url(url).build()
|
|
||||||
val resp = client.newCall(req).execute()
|
|
||||||
if (!resp.isSuccessful) return null
|
|
||||||
|
|
||||||
val json = JSONObject(resp.body?.string() ?: "{}")
|
for (attempt in 1..MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
val result = doFetch(apiBase)
|
||||||
|
if (result != null) return result
|
||||||
|
// null = HTTP error, retry
|
||||||
|
Log.w(TAG, "Fetch attempt $attempt: HTTP error, retrying...")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
Log.w(TAG, "Fetch attempt $attempt failed: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
try { Thread.sleep(RETRY_DELAY_MS) } catch (_: InterruptedException) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.w(TAG, "All $MAX_RETRIES fetch attempts failed", lastException)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single fetch attempt. Returns list on success, null on HTTP error.
|
||||||
|
* Throws on network/parse errors. Always closes the response body.
|
||||||
|
*/
|
||||||
|
private fun doFetch(apiBase: String): List<TerminalItem>? {
|
||||||
|
val url = "$apiBase/session-state"
|
||||||
|
val req = Request.Builder().url(url).build()
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
|
||||||
|
return resp.use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
Log.w(TAG, "HTTP ${response.code} from $url")
|
||||||
|
return@use null
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body?.string() ?: "{}"
|
||||||
|
val json = JSONObject(body)
|
||||||
val registry = json.optJSONArray("registry")
|
val registry = json.optJSONArray("registry")
|
||||||
val agents = json.optJSONObject("agents")
|
val agents = json.optJSONObject("agents")
|
||||||
|
|
||||||
if (registry == null || registry.length() == 0) return emptyList()
|
if (registry == null || registry.length() == 0) return@use emptyList()
|
||||||
|
|
||||||
val result = mutableListOf<TerminalItem>()
|
val result = mutableListOf<TerminalItem>()
|
||||||
|
|
||||||
@@ -190,7 +273,7 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
val ephId = entry.optString("ephemeralSessionId", "")
|
val ephId = entry.optString("ephemeralSessionId", "")
|
||||||
val label = entry.optString("label", "")
|
val label = entry.optString("label", "")
|
||||||
val alive = entry.optBoolean("alive", false)
|
val alive = entry.optBoolean("alive", false)
|
||||||
val terminalIndex = i + 1 // 1-based, maps to /transcript-debug/:terminalIndex
|
val terminalIndex = i + 1
|
||||||
|
|
||||||
val agentState = agents?.optJSONObject(agentName)
|
val agentState = agents?.optJSONObject(agentName)
|
||||||
|
|
||||||
@@ -216,12 +299,7 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep registry order (don't sort) — index must match T1, T2, T3...
|
result
|
||||||
return result
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to fetch terminals", e)
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +309,11 @@ class TerminalListFactory(private val context: Context) : RemoteViewsService.Rem
|
|||||||
val entry = history.optJSONObject(i) ?: continue
|
val entry = history.optJSONObject(i) ?: continue
|
||||||
if (entry.optString("event") == "UserPromptSubmit") {
|
if (entry.optString("event") == "UserPromptSubmit") {
|
||||||
val detail = entry.optString("detail", "")
|
val detail = entry.optString("detail", "")
|
||||||
if (detail.isNotEmpty()) return detail.take(120)
|
if (detail.isNotEmpty()) return detail.take(200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val stopResp = state.optString("lastStopResponse", "")
|
val stopResp = state.optString("lastStopResponse", "")
|
||||||
if (stopResp.isNotEmpty()) return "< ${stopResp.take(100)}"
|
if (stopResp.isNotEmpty()) return "< ${stopResp.take(200)}"
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,57 +6,55 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:paddingTop="3dp"
|
android:paddingTop="3dp"
|
||||||
android:paddingBottom="3dp"
|
android:paddingBottom="3dp"
|
||||||
android:background="?android:attr/selectableItemBackground">
|
android:paddingStart="2dp"
|
||||||
|
android:paddingEnd="2dp"
|
||||||
|
android:background="@drawable/widget_item_bg">
|
||||||
|
|
||||||
<!-- Header: dot + agent name + status + badges -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
<!-- Status dot -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/item_dot"
|
android:id="@+id/item_dot"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="\u25CF"
|
android:text="●"
|
||||||
android:textColor="#6b7280"
|
android:textColor="#6b7280"
|
||||||
android:textSize="8sp"
|
android:textSize="8sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
android:paddingEnd="4dp" />
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
<!-- Agent name + status -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/item_name"
|
android:id="@+id/item_name"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textColor="#DDDDDD"
|
android:textColor="#DDFFFFFF"
|
||||||
android:textSize="10sp"
|
android:textSize="13sp"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
<!-- Hook badges -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/item_badges"
|
android:id="@+id/item_badges"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#888888"
|
android:textColor="#60FFFFFF"
|
||||||
android:textSize="9sp"
|
android:textSize="10sp"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:paddingStart="4dp" />
|
android:paddingStart="6dp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Label / last user prompt -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/item_label"
|
android:id="@+id/item_label"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#9999CC"
|
android:textColor="#80FFFFFF"
|
||||||
android:textSize="10sp"
|
android:textSize="11sp"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:maxLines="2"
|
android:maxLines="3"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:paddingStart="12dp"
|
android:paddingStart="12dp"
|
||||||
android:paddingTop="1dp" />
|
android:paddingTop="1dp" />
|
||||||
|
|||||||
@@ -4,56 +4,93 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp"
|
android:background="@drawable/widget_bg_pixel"
|
||||||
android:background="#DD1A1A2E">
|
android:paddingStart="20dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingTop="18dp"
|
||||||
|
android:paddingBottom="18dp">
|
||||||
|
|
||||||
<!-- Title bar -->
|
<!-- Terminal title bar -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:paddingBottom="4dp">
|
android:paddingBottom="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="~$"
|
||||||
|
android:textColor="#B0FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:paddingEnd="6dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_title"
|
android:id="@+id/widget_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="Agent UI"
|
android:text="agent-ui"
|
||||||
android:textColor="#8888FF"
|
android:textColor="#CCFFFFFF"
|
||||||
android:textSize="11sp"
|
android:textSize="14sp"
|
||||||
android:fontFamily="monospace" />
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_status_bar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="●"
|
||||||
|
android:textColor="#8040C040"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:paddingEnd="8dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/btn_refresh"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="\u21BB"
|
android:text="↻"
|
||||||
android:textColor="#8888FF"
|
android:textColor="#AAFFFFFF"
|
||||||
android:textSize="14sp"
|
android:textSize="18sp"
|
||||||
android:paddingStart="8dp"
|
android:paddingStart="4dp"
|
||||||
android:paddingEnd="4dp"
|
android:paddingEnd="2dp"
|
||||||
android:background="?android:attr/selectableItemBackground" />
|
android:background="?android:attr/selectableItemBackground" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Terminal list (dynamic, scrollable) -->
|
<!-- Separator -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text=""
|
||||||
|
android:textSize="1sp"
|
||||||
|
android:background="#20FFFFFF"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:minHeight="1dp"
|
||||||
|
android:maxHeight="1dp" />
|
||||||
|
|
||||||
|
<!-- Terminal list -->
|
||||||
<ListView
|
<ListView
|
||||||
android:id="@+id/terminal_list"
|
android:id="@+id/terminal_list"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:divider="@null"
|
android:divider="@null"
|
||||||
android:dividerHeight="0dp"
|
android:dividerHeight="0dp"
|
||||||
android:scrollbars="none" />
|
android:scrollbars="none"
|
||||||
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
<!-- Fallback when empty -->
|
<!-- Fallback when empty -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/empty_view"
|
android:id="@+id/empty_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="0dp"
|
||||||
android:text="No terminals open"
|
android:layout_weight="1"
|
||||||
android:textColor="#666666"
|
android:text="no active sessions"
|
||||||
android:textSize="10sp"
|
android:textColor="#50FFFFFF"
|
||||||
|
android:textSize="12sp"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
<string name="app_name">Agent UI</string>
|
<string name="app_name">Agent UI</string>
|
||||||
<string name="main_activity_title">Agent UI</string>
|
<string name="main_activity_title">Agent UI</string>
|
||||||
<string name="widget_description">Shows recent transcript messages from Agent UI</string>
|
<string name="widget_description">Shows recent transcript messages from Agent UI</string>
|
||||||
|
<string name="input_widget_description">Terminal-style input prompt for Agent UI</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:minWidth="250dp"
|
android:minWidth="250dp"
|
||||||
android:minHeight="140dp"
|
android:minHeight="110dp"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:initialLayout="@layout/widget_transcript"
|
android:initialLayout="@layout/widget_transcript"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend/dist",
|
"frontendDist": "../frontend/dist",
|
||||||
"devUrl": "http://localhost:4100",
|
"devUrl": "http://localhost:4100",
|
||||||
"beforeDevCommand": "",
|
"beforeDevCommand": "cd frontend && bun run dev --host --port 4100",
|
||||||
"beforeBuildCommand": "cd frontend && npx vite build"
|
"beforeBuildCommand": "cd frontend && npx vite build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
|
"transparent": true,
|
||||||
"resizable": true
|
"resizable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user