feat: compact boundary divider, overlay fix, approval window, PiP, Tauri enhancements

- Add CompactBoundaryDivider component for compact_boundary system messages
- Fix readability overlay: v-if removes element entirely at 0% opacity
- Add approval page and window composable
- Add PiP window support and loading screen
- Tauri: add window management commands and capabilities
- Disable Ctrl+1..5 shortcuts in Tauri (handled by global shortcuts)
This commit is contained in:
2026-02-24 12:13:15 -06:00
parent a92e4ffbda
commit 78978813cd
23 changed files with 1616 additions and 34 deletions

View File

@@ -12,6 +12,7 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",
@@ -2667,6 +2668,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-global-shortcut": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz",
"integrity": "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",

View File

@@ -24,6 +24,7 @@
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: transparent; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; }
.wrap { position: relative; width: 36px; height: 36px; }
.dot {
width: 36px; height: 36px;
border: 2.5px solid rgba(255,255,255,0.06);
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.6s linear infinite;
filter: drop-shadow(0 0 8px rgba(99,102,241,0.5));
}
.badge {
display: none;
position: absolute; top: -4px; right: -6px;
background: #ef4444; color: #fff;
font: 700 9px/1 -apple-system, sans-serif;
min-width: 14px; height: 14px;
border-radius: 7px;
text-align: center;
padding: 2px 3px;
}
.badge.show { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="wrap">
<div class="dot"></div>
<div class="badge" id="b"></div>
</div>
<script>
var c = new URLSearchParams(location.search).get('n');
if (c && parseInt(c) > 1) { var b = document.getElementById('b'); b.textContent = c; b.className = 'badge show'; }
</script>
</body>
</html>

View File

@@ -22,7 +22,9 @@ import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state'
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
import { initApprovalNotifications } from './services/approvalNotifications'
import { useServerConfig } from './stores/server-config'
import { useApprovalWindow } from './composables/useApprovalWindow'
const route = useRoute()
const router = useRouter()
@@ -32,7 +34,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 isPipWindow = computed(() => route.query.pip === '1' || route.query.window === '1')
const showVoice = ref(false)
const showTranscriptDebug = ref(false)
const showDebugConsole = ref(false)
@@ -85,6 +87,15 @@ const canvasStore = useCanvasStore()
const projectCanvasStore = useProjectCanvasStore()
const sessionState = useSessionState()
const { totalPending, modalVisible, connect: connectApproval, disconnect: disconnectApproval, fetchPending: fetchApprovalPending } = useGlobalApproval()
const { approvalWindowOpen, openApprovalWindow } = useApprovalWindow()
function handleApprovalBadgeClick() {
if (isTauri) {
openApprovalWindow()
} else {
modalVisible.value = !modalVisible.value
}
}
// Voice FAB push-to-talk state
const voicePTTActive = ref(false)
let voiceTouchStarted = false
@@ -173,6 +184,16 @@ function handleGlobalKeydown(e: KeyboardEvent) {
showTranscriptDebug.value = !showTranscriptDebug.value
}
}
// Ctrl+W → close current window (PiP closes, main hides to tray)
if (e.ctrlKey && e.key === 'w') {
e.preventDefault()
if (isTauri) {
import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) => {
getCurrentWebviewWindow().close()
}).catch(() => {})
}
}
}
// Voice FAB push-to-talk handlers
@@ -249,6 +270,11 @@ onMounted(async () => {
connectApproval()
fetchApprovalPending()
// Initialize native approval notifications (Tauri only)
if (isTauri) {
initApprovalNotifications()
}
// Connect centralized session state WS
initSessionStateWS()
@@ -309,21 +335,145 @@ onMounted(async () => {
async function sendTestNotification() {
const title = 'Agent UI'
const body = 'Test notification from Agent UI — all platforms!'
const body = 'Test notification — ' + new Date().toLocaleTimeString()
if (isTauri) {
const isMobile = isMobileTauri()
try {
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
let granted = await isPermissionGranted()
const { invoke } = await import('@tauri-apps/api/core')
// ── Step 1: Check permission ──
console.log('[TestNotif] === START === isMobile:', isMobile)
console.log('[TestNotif] Step 1: checking permission...')
let granted = false
if (isMobile) {
// Android: use Kotlin @Command methods directly (bypass window.Notification polyfill)
// check_permissions → Kotlin checkPermissions (auto snake→camel routing)
try {
const check = await invoke<{ permissionState: string }>('plugin:notification|check_permissions')
console.log('[TestNotif] check_permissions result:', JSON.stringify(check))
granted = check?.permissionState === 'granted'
} catch (e) {
console.warn('[TestNotif] check_permissions failed, trying is_permission_granted:', e)
try {
const result = await invoke<boolean | null>('plugin:notification|is_permission_granted')
console.log('[TestNotif] is_permission_granted:', result)
granted = result === true
} catch (e2) {
console.error('[TestNotif] is_permission_granted also failed:', e2)
}
}
// ── Step 2: Request if needed ──
if (!granted) {
console.log('[TestNotif] Step 2: requesting permission...')
try {
const req = await invoke<{ permissionState: string }>('plugin:notification|request_permissions')
console.log('[TestNotif] request_permissions result:', JSON.stringify(req))
granted = req?.permissionState === 'granted'
} catch (e) {
console.warn('[TestNotif] request_permissions failed, trying request_permission:', e)
try {
const perm = await invoke<string>('plugin:notification|request_permission')
console.log('[TestNotif] request_permission (Rust):', perm)
granted = perm === 'granted'
} catch (e2) {
console.error('[TestNotif] request_permission also failed:', e2)
}
}
}
} else {
// Desktop: JS wrapper works fine (window.Notification exists on desktop WebView)
try {
const { isPermissionGranted, requestPermission } = await getTauriNotification()
granted = await isPermissionGranted()
console.log('[TestNotif] Desktop isPermissionGranted:', granted)
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
}
if (granted) {
sendNotification({ title, body })
console.log('[TestNotif] Desktop requestPermission:', perm)
}
} catch (e) {
console.warn('[Notification] Tauri plugin failed:', e)
console.warn('[TestNotif] Desktop permission check failed:', e)
granted = true // Desktop usually grants by default
}
}
console.log('[TestNotif] Permission granted:', granted)
if (!granted) {
console.error('[TestNotif] STOPPED: Permission denied')
return
}
// ── Step 3: Ensure channel exists (Android only) ──
if (isMobile) {
console.log('[TestNotif] Step 3: ensuring channel exists...')
try {
await invoke('plugin:notification|create_channel', {
id: 'test',
name: 'Test',
description: 'Test notifications',
importance: 4,
visibility: 1,
vibration: true,
lights: true,
})
console.log('[TestNotif] Channel "test" created/updated')
} catch (e) {
console.warn('[TestNotif] create_channel failed (will use default):', e)
}
}
// ── Step 4: Send notification via invoke ──
console.log('[TestNotif] Step 4: sending via invoke...')
const notifId = Math.floor(Math.random() * 100000)
try {
const result = await invoke('plugin:notification|notify', {
options: {
id: notifId,
title,
body,
channelId: isMobile ? 'test' : undefined,
autoCancel: true,
}
})
console.log('[TestNotif] notify result:', JSON.stringify(result), 'id:', notifId)
} catch (e) {
console.error('[TestNotif] notify FAILED:', e)
// Fallback: try without channel (uses "default")
if (isMobile) {
console.log('[TestNotif] Retrying without channelId...')
try {
const result2 = await invoke('plugin:notification|notify', {
options: { id: notifId + 1, title, body: body + ' (default channel)', autoCancel: true }
})
console.log('[TestNotif] notify (default channel) result:', JSON.stringify(result2))
} catch (e2) {
console.error('[TestNotif] notify (default channel) also FAILED:', e2)
}
}
}
// ── Step 5: Diagnostic — list active notifications ──
if (isMobile) {
try {
const active = await invoke('plugin:notification|get_active')
console.log('[TestNotif] Active notifications:', JSON.stringify(active))
} catch (e) {
console.warn('[TestNotif] get_active failed:', e)
}
try {
const channels = await invoke('plugin:notification|listChannels')
console.log('[TestNotif] Channels:', JSON.stringify(channels))
} catch (e) {
console.warn('[TestNotif] listChannels failed:', e)
}
}
console.log('[TestNotif] === DONE ===')
} catch (e) {
console.error('[TestNotif] Fatal error:', e)
}
} else if ('Notification' in window) {
if (Notification.permission === 'granted') {
@@ -404,10 +554,10 @@ if (serverConfig) {
</div>
<div class="header-right">
<button
v-if="totalPending > 0 || modalVisible"
v-if="totalPending > 0 || modalVisible || approvalWindowOpen"
class="approval-badge-btn"
:class="{ active: modalVisible, pulse: totalPending > 0 }"
@click="modalVisible = !modalVisible"
:class="{ active: modalVisible || approvalWindowOpen, pulse: totalPending > 0 }"
@click="handleApprovalBadgeClick"
title="Hooks Approval"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -506,8 +656,8 @@ if (serverConfig) {
<!-- Floating Transcript Debug -->
<FloatingTranscriptDebug ref="transcriptDebugRef" v-model="showTranscriptDebug" />
<!-- Global Hooks Approval Modal -->
<HooksApprovalModal />
<!-- Global Hooks Approval Modal (web-only; Tauri desktop uses approval-window) -->
<HooksApprovalModal v-if="!isTauri" />
<!-- Tauri Server Config Dialog -->
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />

View File

@@ -4,6 +4,7 @@ import { useTranscriptDebug } from '@/composables/transcript-debug'
import { useVoiceInput } from '@/composables/useVoiceInput'
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
import { isTauri } from '@/lib/tauri'
const props = defineProps<{
modelValue: boolean
@@ -506,7 +507,8 @@ watch(isOpen, async (open) => {
function handleGlobalKeydown(e: KeyboardEvent) {
if (!e.ctrlKey) return
// Ctrl+1..5 → switch to terminal by index
// Ctrl+1..5 → switch to terminal by index (disabled in Tauri — handled by global shortcuts)
if (!isTauri) {
const num = parseInt(e.key)
if (num >= 1 && num <= 5) {
const terminal = openTerminals.value[num - 1]
@@ -516,6 +518,7 @@ function handleGlobalKeydown(e: KeyboardEvent) {
switchToTerminal(terminal.sessionId)
return
}
}
// Zoom shortcuts (only when open)
if (!isOpen.value) return
@@ -710,7 +713,7 @@ onBeforeUnmount(() => {
<!-- Content -->
<div class="content">
<AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<div v-if="overlayOpacity > 0" class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<Transition name="terminal-loading">
<div v-if="transitioning" class="terminal-loading-overlay">
<div class="terminal-loading-spinner" />

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { ParsedSystemMessage } from '@/types/transcript-debug'
defineProps<{
message: ParsedSystemMessage
}>()
</script>
<template>
<div class="compact-boundary">
<div class="compact-line">
<!-- Left compression wave -->
<svg class="wave wave-left" viewBox="0 0 200 14" preserveAspectRatio="xMinYMid slice" shape-rendering="crispEdges">
<!-- Base void layer -->
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
<!-- Compression chevrons pushing right -->
<rect x="4" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
<rect x="5" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
<rect x="6" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
<rect x="14" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
<rect x="15" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
<rect x="16" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
<rect x="26" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
<rect x="27" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="28" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
<rect x="40" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="41" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
<rect x="42" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
<rect x="56" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
<rect x="57" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="58" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
<rect x="74" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="75" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
<rect x="76" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
<rect x="94" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
<rect x="95" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="96" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
<rect x="116" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="117" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
<rect x="118" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
<rect x="140" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
<rect x="141" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="142" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
<rect x="160" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="161" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
<rect x="162" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="178" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
<rect x="179" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="180" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
<!-- Data particles being pulled inward -->
<rect x="8" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
<rect x="20" y="5" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
<rect x="34" y="8" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
<rect x="48" y="4" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
<rect x="52" y="9" width="2" height="1" fill="#fde68a" opacity="0.3"/>
<rect x="66" y="5" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="68" y="8" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
<rect x="84" y="4" width="1" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="86" y="9" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="104" y="6" width="2" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="108" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
<rect x="128" y="5" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<rect x="132" y="8" width="1" height="1" fill="#fde68a" opacity="0.5"/>
<rect x="150" y="4" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
<rect x="154" y="9" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="170" y="6" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="186" y="5" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="192" y="7" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
</svg>
<!-- Center badge -->
<span class="compact-badge">Compactado</span>
<!-- Right compression wave -->
<svg class="wave wave-right" viewBox="0 0 200 14" preserveAspectRatio="xMaxYMid slice" shape-rendering="crispEdges">
<!-- Base void layer -->
<rect x="0" y="6" width="200" height="2" fill="#f59e0b" opacity="0.08"/>
<!-- Compression chevrons pushing left (mirrored) -->
<rect x="195" y="5" width="1" height="4" fill="#f59e0b" opacity="0.15"/>
<rect x="194" y="4" width="1" height="6" fill="#f59e0b" opacity="0.18"/>
<rect x="193" y="3" width="1" height="8" fill="#f59e0b" opacity="0.2"/>
<rect x="185" y="5" width="1" height="4" fill="#f59e0b" opacity="0.2"/>
<rect x="184" y="4" width="1" height="6" fill="#f59e0b" opacity="0.25"/>
<rect x="183" y="3" width="1" height="8" fill="#f59e0b" opacity="0.28"/>
<rect x="173" y="5" width="1" height="4" fill="#f59e0b" opacity="0.25"/>
<rect x="172" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="171" y="3" width="1" height="8" fill="#fbbf24" opacity="0.32"/>
<rect x="159" y="4" width="1" height="6" fill="#f59e0b" opacity="0.3"/>
<rect x="158" y="3" width="1" height="8" fill="#fbbf24" opacity="0.35"/>
<rect x="157" y="2" width="1" height="10" fill="#fbbf24" opacity="0.38"/>
<rect x="143" y="4" width="1" height="6" fill="#fbbf24" opacity="0.35"/>
<rect x="142" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="141" y="2" width="1" height="10" fill="#fbbf24" opacity="0.42"/>
<rect x="125" y="3" width="1" height="8" fill="#fbbf24" opacity="0.4"/>
<rect x="124" y="2" width="1" height="10" fill="#fbbf24" opacity="0.45"/>
<rect x="123" y="1" width="1" height="12" fill="#fbbf24" opacity="0.48"/>
<rect x="105" y="3" width="1" height="8" fill="#fbbf24" opacity="0.45"/>
<rect x="104" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="103" y="1" width="1" height="12" fill="#fde68a" opacity="0.52"/>
<rect x="83" y="2" width="1" height="10" fill="#fde68a" opacity="0.5"/>
<rect x="82" y="1" width="1" height="12" fill="#fde68a" opacity="0.55"/>
<rect x="81" y="0" width="1" height="14" fill="#fef3c7" opacity="0.55"/>
<rect x="59" y="2" width="1" height="10" fill="#fde68a" opacity="0.55"/>
<rect x="58" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="57" y="0" width="1" height="14" fill="#fef3c7" opacity="0.6"/>
<rect x="39" y="1" width="1" height="12" fill="#fef3c7" opacity="0.58"/>
<rect x="38" y="0" width="1" height="14" fill="#fef3c7" opacity="0.62"/>
<rect x="37" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="21" y="1" width="1" height="12" fill="#fef3c7" opacity="0.6"/>
<rect x="20" y="0" width="1" height="14" fill="#fffbeb" opacity="0.65"/>
<rect x="19" y="0" width="1" height="14" fill="#fffbeb" opacity="0.7"/>
<!-- Data particles being pulled inward -->
<rect x="190" y="6" width="2" height="2" fill="#f59e0b" opacity="0.2"/>
<rect x="178" y="8" width="2" height="1" fill="#fbbf24" opacity="0.25"/>
<rect x="164" y="5" width="2" height="1" fill="#f59e0b" opacity="0.3"/>
<rect x="151" y="9" width="1" height="1" fill="#fbbf24" opacity="0.35"/>
<rect x="146" y="4" width="2" height="1" fill="#fde68a" opacity="0.3"/>
<rect x="133" y="8" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="130" y="5" width="2" height="1" fill="#f59e0b" opacity="0.35"/>
<rect x="115" y="9" width="1" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="112" y="4" width="1" height="1" fill="#fbbf24" opacity="0.4"/>
<rect x="94" y="7" width="2" height="1" fill="#fde68a" opacity="0.45"/>
<rect x="90" y="3" width="1" height="1" fill="#fbbf24" opacity="0.5"/>
<rect x="71" y="8" width="1" height="1" fill="#fef3c7" opacity="0.5"/>
<rect x="67" y="5" width="1" height="1" fill="#fde68a" opacity="0.5"/>
<rect x="49" y="9" width="1" height="1" fill="#fef3c7" opacity="0.55"/>
<rect x="45" y="4" width="1" height="1" fill="#fde68a" opacity="0.55"/>
<rect x="29" y="7" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="13" y="8" width="1" height="1" fill="#fffbeb" opacity="0.6"/>
<rect x="7" y="6" width="1" height="1" fill="#fffbeb" opacity="0.65"/>
</svg>
</div>
</div>
</template>
<style scoped>
.compact-boundary {
padding: 0.25rem 0;
user-select: none;
}
.compact-line {
display: flex;
align-items: center;
gap: 0;
height: 28px;
}
.wave {
flex: 1;
height: 100%;
image-rendering: pixelated;
overflow: hidden;
}
.compact-badge {
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
font-family: 'Courier New', monospace;
color: rgba(245, 158, 11, 0.85);
padding: 0 6px;
letter-spacing: 1px;
text-transform: uppercase;
z-index: 1;
animation: compress-pulse 3s ease-in-out infinite;
}
@keyframes compress-pulse {
0%, 100% {
text-shadow: 0 0 4px rgba(245, 158, 11, 0.2);
color: rgba(245, 158, 11, 0.7);
}
50% {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.5), 0 0 3px rgba(251, 191, 36, 0.3);
color: rgba(245, 158, 11, 0.95);
}
}
</style>

View File

@@ -0,0 +1,124 @@
import { ref } from 'vue'
import { isTauri, getTauriStore } from '@/lib/tauri'
import { trackLoading } from '@/lib/loadingWindow'
const LABEL = 'approval-window'
const STORE_KEY = 'approvalWindow.geometry'
const DEFAULT_WIDTH = 520
const DEFAULT_HEIGHT = 480
interface WindowGeometry {
x: number
y: number
width: number
height: number
}
const approvalWindowOpen = ref(false)
async function loadGeometry(): Promise<WindowGeometry | null> {
try {
const store = await getTauriStore()
return await store.get<WindowGeometry>(STORE_KEY) ?? null
} catch {
return null
}
}
async function saveGeometry(geo: WindowGeometry): Promise<void> {
try {
const store = await getTauriStore()
await store.set(STORE_KEY, geo)
} catch {}
}
export function useApprovalWindow() {
async function openApprovalWindow(): Promise<boolean> {
if (!isTauri) return false
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
// If already open, just focus it
const existing = await WebviewWindow.getByLabel(LABEL)
if (existing) {
await existing.setFocus()
return true
}
// Restore last position/size or use defaults
const saved = await loadGeometry()
const x = saved?.x ?? Math.round(window.screenX + window.outerWidth - DEFAULT_WIDTH - 20)
const y = saved?.y ?? Math.round(window.screenY + 60)
const width = saved?.width ?? DEFAULT_WIDTH
const height = saved?.height ?? DEFAULT_HEIGHT
// Track in the shared loading indicator (bottom-right spinner)
const dismissLoading = await trackLoading(LABEL)
// Create real window hidden
const win = new WebviewWindow(LABEL, {
url: '/approval?window=1',
title: 'Hooks Approval',
width,
height,
x,
y,
visible: false,
alwaysOnTop: true,
decorations: false,
resizable: true,
focus: true,
})
approvalWindowOpen.value = true
// When real window is ready: show it, remove from loading tracker
win.once('tauri://webview-created', () => {
setTimeout(async () => {
try { await win.show() } catch {}
try { await win.setFocus() } catch {}
await dismissLoading()
}, 200)
})
// Persist geometry on close
win.onCloseRequested(async () => {
try {
const pos = await win.outerPosition()
const size = await win.outerSize()
await saveGeometry({
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
})
} catch {}
approvalWindowOpen.value = false
})
return true
} catch (e) {
console.warn('[ApprovalWindow] Failed to open:', e)
approvalWindowOpen.value = false
return false
}
}
async function closeApprovalWindow(): Promise<void> {
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existing = await WebviewWindow.getByLabel(LABEL)
if (existing) await existing.close()
} catch {}
approvalWindowOpen.value = false
}
return {
approvalWindowOpen,
openApprovalWindow,
closeApprovalWindow,
}
}

View File

@@ -1,6 +1,6 @@
import { ref, computed, watch } from 'vue'
import { useSessionState } from '@/stores/session-state'
import { apiFetch } from '@/lib/tauri'
import { apiFetch, isTauri } from '@/lib/tauri'
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
export interface ApprovalSessionGroup {
@@ -68,13 +68,53 @@ export function useGlobalApproval() {
return Array.from(map.values())
})
// Auto-show modal when new approvals arrive
watch(totalPending, (val, oldVal) => {
if (val > 0 && (oldVal === 0 || oldVal === undefined) && !modalVisible.value) {
// Auto-show modal (web) or open approval window (Tauri desktop) when new approvals arrive
const isSubWindow = window.location.search.includes('window=1') || window.location.search.includes('pip=1')
watch(totalPending, async (val, oldVal) => {
if (val > 0 && (oldVal === 0 || oldVal === undefined)) {
if (isTauri && !isSubWindow) {
// Desktop Tauri: open separate approval window
const { useApprovalWindow } = await import('./useApprovalWindow')
const { openApprovalWindow } = useApprovalWindow()
openApprovalWindow()
} else if (!isTauri && !modalVisible.value) {
// Web mode: show in-app modal
modalVisible.value = true
}
}
})
// ── Native notification watch (Tauri main window only) ──
if (isTauri && !isSubWindow) {
let prevIds = new Set<string>()
watch(
() => sessionStore.allPendingApprovals,
async (current) => {
const currentIds = new Set(current.map(a => a.requestId))
// New approvals → send notification
const added = current.filter(a => !prevIds.has(a.requestId))
for (const approval of added) {
const { sendApprovalNotification } = await import('@/services/approvalNotifications')
sendApprovalNotification(approval)
}
// Removed approvals → cancel notification
for (const id of prevIds) {
if (!currentIds.has(id)) {
const { cancelApprovalNotification } = await import('@/services/approvalNotifications')
cancelApprovalNotification(id)
}
}
prevIds = currentIds
},
{ deep: true }
)
}
// ── Actions (call API, server broadcasts resolution to all clients) ──
async function respondPermission(requestId: string, decision: string, reason?: string) {

View File

@@ -0,0 +1,123 @@
import { ref } from 'vue'
import { isTauri, getTauriStore } from '@/lib/tauri'
import { trackLoading } from '@/lib/loadingWindow'
const STORE_KEY_PREFIX = 'pipWindow.geometry.'
interface WindowGeometry {
x: number
y: number
width: number
height: number
}
const pipWindows = ref<Map<number, boolean>>(new Map())
async function loadGeometry(idx: number): Promise<WindowGeometry | null> {
try {
const store = await getTauriStore()
return await store.get<WindowGeometry>(`${STORE_KEY_PREFIX}${idx}`) ?? null
} catch {
return null
}
}
async function saveGeometry(idx: number, geo: WindowGeometry): Promise<void> {
try {
const store = await getTauriStore()
await store.set(`${STORE_KEY_PREFIX}${idx}`, geo)
} catch {}
}
export function usePipWindow() {
async function openPip(terminalIndex: number): Promise<boolean> {
if (!isTauri) return false
const pipLabel = `pip-terminal-${terminalIndex}`
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existing = await WebviewWindow.getByLabel(pipLabel)
if (existing) {
await existing.setFocus()
return true
}
const pipUrl = `/transcript-debug/${terminalIndex}?pip=1`
// Restore last position/size or use defaults
const saved = await loadGeometry(terminalIndex)
const x = saved?.x ?? (window.screen.width - 400)
const y = saved?.y ?? (60 + (terminalIndex - 1) * 40)
const width = saved?.width ?? 380
const height = saved?.height ?? 620
// Track in the shared loading indicator (bottom-right spinner)
const dismissLoading = await trackLoading(pipLabel)
// Create real window hidden
const pip = new WebviewWindow(pipLabel, {
url: pipUrl,
title: `T${terminalIndex} - Agent UI`,
width,
height,
x,
y,
visible: false,
alwaysOnTop: false,
decorations: false,
resizable: true,
focus: true,
})
pipWindows.value.set(terminalIndex, true)
// When real window is ready: show it, remove from loading tracker
pip.once('tauri://webview-created', () => {
setTimeout(async () => {
try { await pip.show() } catch {}
try { await pip.setFocus() } catch {}
await dismissLoading()
}, 200)
})
// Persist geometry on close
pip.onCloseRequested(async () => {
try {
const pos = await pip.outerPosition()
const size = await pip.outerSize()
await saveGeometry(terminalIndex, {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
})
} catch {}
pipWindows.value.delete(terminalIndex)
})
return true
} catch (e) {
console.warn(`Failed to open PiP window for T${terminalIndex}:`, e)
pipWindows.value.delete(terminalIndex)
return false
}
}
async function closePip(terminalIndex: number): Promise<void> {
const pipLabel = `pip-terminal-${terminalIndex}`
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const existing = await WebviewWindow.getByLabel(pipLabel)
if (existing) await existing.close()
} catch {}
pipWindows.value.delete(terminalIndex)
}
function isPipOpen(terminalIndex: number): boolean {
return pipWindows.value.get(terminalIndex) ?? false
}
return { openPip, closePip, isPipOpen, pipWindows }
}

View File

@@ -0,0 +1,52 @@
/**
* Tiny transparent loading indicator — fixed to bottom-right of screen.
* Shows a single spinner with a badge count when multiple windows are loading.
* Static HTML (/loading.html?n=count), no Vue, no JS frameworks.
*/
const LABEL = 'loading-window'
const SIZE = 56
const MARGIN = 24
const pending = new Set<string>()
async function syncWindow() {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
// Destroy current loading window (recreate with new count)
try {
const prev = await WebviewWindow.getByLabel(LABEL)
if (prev) await prev.destroy()
} catch {}
if (pending.size === 0) return
const x = window.screen.width - SIZE - MARGIN
const y = window.screen.height - SIZE - MARGIN - 48 // above taskbar
new WebviewWindow(LABEL, {
url: `/loading.html?n=${pending.size}`,
title: '',
width: SIZE,
height: SIZE,
x,
y,
alwaysOnTop: true,
decorations: false,
resizable: false,
focus: false,
transparent: true,
skipTaskbar: true,
})
}
/** Call when a window starts loading. Returns a dismiss function. */
export async function trackLoading(id: string): Promise<() => Promise<void>> {
pending.add(id)
await syncWindow()
return async () => {
pending.delete(id)
await syncWindow()
}
}

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useGlobalApproval } from '@/composables/useGlobalApproval'
import { useSessionState } from '@/stores/session-state'
import PermissionApproval from '@/components/transcript-debug/PermissionApproval.vue'
import PlanApproval from '@/components/transcript-debug/PlanApproval.vue'
const route = useRoute()
const sessionState = useSessionState()
const isWindow = computed(() => route.query.window === '1')
const {
totalPending,
groupedBySession,
respondPermission,
respondPlan,
ignoreApproval
} = useGlobalApproval()
function truncateId(id: string): string {
if (id.length <= 12) return id
return id.slice(0, 6) + '...' + id.slice(-4)
}
// Auto-close window when no pending approvals remain
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null
watch(totalPending, async (val) => {
if (autoCloseTimer) {
clearTimeout(autoCloseTimer)
autoCloseTimer = null
}
if (val === 0 && isWindow.value && sessionState.connected) {
autoCloseTimer = setTimeout(async () => {
try {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
await getCurrentWebviewWindow().close()
} catch {
window.close()
}
}, 1500)
}
})
async function closeWindow() {
try {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
await getCurrentWebviewWindow().close()
} catch {
window.close()
}
}
</script>
<template>
<div class="approval-window">
<!-- Custom drag titlebar -->
<div class="titlebar" data-tauri-drag-region>
<span class="titlebar-title" data-tauri-drag-region>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
Hooks Approval
</span>
<span v-if="totalPending > 0" class="titlebar-count">{{ totalPending }}</span>
<button class="titlebar-close" @click="closeWindow" title="Close">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="approval-body">
<!-- Loading state while WS connects -->
<div v-if="!sessionState.connected" class="loading-state">
<div class="spinner"></div>
<span>Connecting...</span>
</div>
<template v-else-if="groupedBySession.length > 0">
<div v-for="group in groupedBySession" :key="group.sessionId" class="session-group">
<div class="session-header">
<span class="session-agent">{{ group.agent }}</span>
<span class="session-sep">/</span>
<span class="session-id" :title="group.sessionId">{{ truncateId(group.sessionId) }}</span>
<span class="session-count">{{ group.permissions.length + group.plans.length }}</span>
</div>
<PermissionApproval
v-for="perm in group.permissions"
:key="perm.requestId"
:request="perm"
@respond="(id, decision, reason) => respondPermission(id, decision, reason)"
@ignore="(id) => ignoreApproval(id)"
/>
<PlanApproval
v-for="plan in group.plans"
:key="plan.requestId"
:request="plan"
@respond="(id, decision, reason) => respondPlan(id, decision, reason)"
/>
</div>
</template>
<div v-else class="empty-state">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.4">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>No pending approvals</span>
</div>
</div>
</div>
</template>
<style scoped>
.approval-window {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--border-color);
}
/* ── Titlebar ── */
.titlebar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
user-select: none;
-webkit-app-region: drag;
}
.titlebar-title {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
-webkit-app-region: drag;
}
.titlebar-title svg {
color: #f59e0b;
}
.titlebar-count {
background: #ef4444;
color: white;
font-size: 10px;
font-weight: 700;
padding: 0.05rem 0.4rem;
border-radius: 10px;
min-width: 16px;
text-align: center;
line-height: 1.4;
}
.titlebar-close {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
-webkit-app-region: no-drag;
}
.titlebar-close:hover {
background: #ef4444;
color: white;
}
/* ── Body ── */
.approval-body {
flex: 1;
overflow-y: auto;
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.session-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.session-header {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0;
}
.session-agent {
font-size: 11px;
font-weight: 600;
color: var(--accent, #6366f1);
}
.session-sep {
font-size: 10px;
color: var(--text-muted);
opacity: 0.4;
}
.session-id {
font-size: 10px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
}
.session-count {
margin-left: auto;
font-size: 9px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-hover);
padding: 0.1rem 0.35rem;
border-radius: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 1.5rem;
color: var(--text-muted);
font-size: 12px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: var(--text-muted);
font-size: 12px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--accent, #6366f1);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -340,7 +340,7 @@ onBeforeUnmount(() => {
<!-- Content -->
<div :class="['content-area', { 'selector-open': showSelector }, `nav-${scrollNavMode}`]">
<AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<div v-if="overlayOpacity > 0" class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<Transition name="terminal-loading">
<div v-if="transitioning" class="loading-overlay">

View File

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

View File

@@ -0,0 +1,360 @@
/**
* Native OS notifications with action buttons for approval requests.
* Uses @tauri-apps/plugin-notification v2.
*
* - Permission approvals → Allow / Deny buttons
* - Plan approvals → Approve / Reject buttons
* - Questions → Open button (brings up approval window)
*
* ANDROID NOTES:
* The official JS wrapper (`sendNotification`, `isPermissionGranted`, `requestPermission`)
* all use `window.Notification` (Web API / polyfill). On Android WebView this polyfill is
* injected by init-iife.js but has timing issues and fire-and-forget invoke calls.
*
* We bypass the JS wrappers entirely and call `invoke()` directly for:
* - Permission: `checkPermissions` / `requestPermissions` (native Kotlin commands)
* - Send: `notify` (goes Rust → Kotlin show())
* - Channel: `create_channel` (Kotlin createChannel)
*
* See: https://github.com/tauri-apps/plugins-workspace/issues/2341
*/
import { isTauri, isMobileTauri } from '@/lib/tauri'
import type { PendingApproval } from '@/stores/session-state'
/** Resolves when init is fully done (channel created, action types registered, listener set). */
let _readyPromise: Promise<boolean> | null = null
// ── Helpers ──
/** Hash a string to a stable 32-bit positive integer (for notification IDs). */
function hashRequestId(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
type ApprovalKind = 'permission' | 'plan' | 'question'
function classifyApproval(approval: PendingApproval & { agent: string }): ApprovalKind {
if (approval.type === 'plan') return 'plan'
if (approval.toolName === 'AskUserQuestion') return 'question'
return 'permission'
}
// ── Public API ──
/**
* Call once from App.vue on mount (guarded by isTauri).
* Registers action types, Android channel, and the onAction listener.
* Returns true if ready to send notifications.
*/
export function initApprovalNotifications(): Promise<boolean> {
if (_readyPromise) return _readyPromise
if (!isTauri) {
_readyPromise = Promise.resolve(false)
return _readyPromise
}
_readyPromise = doInit()
return _readyPromise
}
async function doInit(): Promise<boolean> {
const isMobile = isMobileTauri()
try {
const { invoke } = await import('@tauri-apps/api/core')
const {
registerActionTypes,
onAction,
} = await import('@tauri-apps/plugin-notification')
// ── 1. Permission check & request ──
// Use native Kotlin commands directly instead of JS wrappers that depend on
// window.Notification polyfill (unreliable on Android WebView).
console.log('[ApprovalNotif] Step 1: checking permission...')
let granted = false
if (isMobile) {
// Android path: use checkPermissions / requestPermissions (Kotlin @Command)
// These route JS → Rust (not found) → Kotlin via mobile plugin auto-routing
try {
const check = await invoke<{ permissionState: string }>('plugin:notification|check_permissions')
console.log('[ApprovalNotif] checkPermissions:', JSON.stringify(check))
granted = check?.permissionState === 'granted'
if (!granted) {
console.log('[ApprovalNotif] Requesting permission via requestPermissions...')
const req = await invoke<{ permissionState: string }>('plugin:notification|request_permissions')
console.log('[ApprovalNotif] requestPermissions result:', JSON.stringify(req))
granted = req?.permissionState === 'granted'
}
} catch (e) {
console.error('[ApprovalNotif] Android mobile permission commands failed:', e)
// Fallback: try Rust-side is_permission_granted + request_permission
try {
const result = await invoke<boolean | null>('plugin:notification|is_permission_granted')
console.log('[ApprovalNotif] is_permission_granted fallback:', result)
granted = result === true
if (!granted) {
console.log('[ApprovalNotif] Requesting via request_permission (Rust)...')
const perm = await invoke<string>('plugin:notification|request_permission')
console.log('[ApprovalNotif] request_permission result:', perm)
granted = perm === 'granted'
}
} catch (e2) {
console.error('[ApprovalNotif] All permission checks failed:', e2)
}
}
} else {
// Desktop path: use the JS wrapper (window.Notification exists on desktop WebView)
try {
const { isPermissionGranted, requestPermission } = await import('@tauri-apps/plugin-notification')
granted = await isPermissionGranted()
console.log('[ApprovalNotif] Desktop isPermissionGranted:', granted)
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
console.log('[ApprovalNotif] Desktop requestPermission:', perm)
}
} catch (e) {
console.warn('[ApprovalNotif] Desktop permission check failed:', e)
granted = true // Desktop usually grants by default
}
}
if (!granted) {
console.warn('[ApprovalNotif] Notification permission denied')
return false
}
console.log('[ApprovalNotif] Permission granted')
// ── 2. Android channel (high importance = heads-up) ──
// On Android 8+, notifications REQUIRE a channel or they won't show.
// The plugin creates a "default" channel on load, but we create our own
// "approvals" channel with high importance for heads-up display.
console.log('[ApprovalNotif] Step 2: creating channel...')
try {
await invoke('plugin:notification|create_channel', {
id: 'approvals',
name: 'Approvals',
description: 'Approval requests from Claude Code hooks',
importance: 4, // High — raw int instead of enum to avoid import issues
visibility: 1, // Public
vibration: true,
lights: true,
})
console.log('[ApprovalNotif] Channel "approvals" created')
} catch (e) {
if (isMobile) {
console.error('[ApprovalNotif] createChannel failed on mobile:', e)
// Don't return false — the "default" channel exists as fallback
}
// On desktop, createChannel is not supported — that's fine
}
// ── 3. Register action types (button groups) ──
console.log('[ApprovalNotif] Step 3: registering action types...')
try {
await registerActionTypes([
{
id: 'permission-actions',
actions: [
{ id: 'allow', title: 'Allow' },
{ id: 'deny', title: 'Deny', destructive: true },
],
},
{
id: 'plan-actions',
actions: [
{ id: 'approve', title: 'Approve' },
{ id: 'reject', title: 'Reject', destructive: true },
],
},
{
id: 'question-actions',
actions: [
{ id: 'open', title: 'Open' },
],
},
])
console.log('[ApprovalNotif] Action types registered')
} catch (e) {
console.warn('[ApprovalNotif] registerActionTypes failed:', e)
}
// ── 4. onAction listener — handle button clicks ──
await onAction(async (notification) => {
const extra = (notification as any).extra as Record<string, string> | undefined
const requestId = extra?.requestId
const type = extra?.type as ApprovalKind | undefined
const actionId = (notification as any).actionId as string | undefined
console.log('[ApprovalNotif] onAction:', { actionId, type, requestId })
if (!requestId) {
await openApprovalWindowFromNotification()
return
}
const { apiFetch } = await import('@/lib/tauri')
if (type === 'permission') {
if (actionId === 'allow' || actionId === 'deny') {
await apiFetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision: actionId }),
}).catch(e => console.error('[ApprovalNotif] respond failed:', e))
return
}
}
if (type === 'plan') {
if (actionId === 'approve' || actionId === 'reject') {
await apiFetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision: actionId }),
}).catch(e => console.error('[ApprovalNotif] respond-plan failed:', e))
return
}
}
await openApprovalWindowFromNotification()
})
console.log('[ApprovalNotif] Initialized successfully')
return true
} catch (e) {
console.error('[ApprovalNotif] Init failed:', e)
return false
}
}
/**
* Send a native notification for a pending approval.
* Uses `invoke('plugin:notification|notify')` directly instead of the JS wrapper.
*/
export async function sendApprovalNotification(
approval: PendingApproval & { agent: string }
): Promise<void> {
if (!isTauri) return
const ready = _readyPromise ? await _readyPromise : false
if (!ready) {
console.warn('[ApprovalNotif] Not ready, skipping notification for', approval.requestId)
return
}
try {
const { invoke } = await import('@tauri-apps/api/core')
const kind = classifyApproval(approval)
const id = hashRequestId(approval.requestId)
let title = 'Agent UI'
let body = ''
let actionTypeId = ''
switch (kind) {
case 'permission':
title = `Permission: ${approval.toolName || 'unknown tool'}`
body = truncate(summarizeToolInput(approval.toolInput), 200)
actionTypeId = 'permission-actions'
break
case 'plan':
title = 'Plan approval requested'
body = truncate(approval.lastAssistantText || 'Review the plan', 200)
actionTypeId = 'plan-actions'
break
case 'question':
title = 'Question from Claude'
body = truncate(extractQuestionText(approval.toolInput), 200)
actionTypeId = 'question-actions'
break
}
const isMobile = isMobileTauri()
await invoke('plugin:notification|notify', {
options: {
id,
title,
body,
// On Android: use our custom channel. On desktop: ignored.
channelId: isMobile ? 'approvals' : undefined,
actionTypeId,
autoCancel: true,
extra: {
requestId: approval.requestId,
type: kind,
},
}
})
console.log(`[ApprovalNotif] Sent id=${id} kind=${kind} req=${approval.requestId}`)
} catch (e) {
console.error('[ApprovalNotif] Failed to send:', e)
}
}
/** Cancel a notification when the approval is resolved from the UI. */
export async function cancelApprovalNotification(requestId: string): Promise<void> {
if (!isTauri) return
const ready = _readyPromise ? await _readyPromise : false
if (!ready) return
try {
const { invoke } = await import('@tauri-apps/api/core')
const id = hashRequestId(requestId)
await invoke('plugin:notification|remove_active', {
notifications: [{ id }]
})
console.log(`[ApprovalNotif] Cancelled id=${id}`)
} catch (e) {
console.warn('[ApprovalNotif] Cancel failed:', e)
}
}
// ── Internal helpers ──
async function openApprovalWindowFromNotification(): Promise<void> {
try {
const { useApprovalWindow } = await import('@/composables/useApprovalWindow')
const { openApprovalWindow } = useApprovalWindow()
await openApprovalWindow()
} catch (e) {
console.error('[ApprovalNotif] Failed to open approval window:', e)
}
}
function summarizeToolInput(input: unknown): string {
if (!input) return ''
if (typeof input === 'string') return input
const obj = input as Record<string, unknown>
if (obj.command && typeof obj.command === 'string') return obj.command
if (obj.file_path && typeof obj.file_path === 'string') return obj.file_path as string
return JSON.stringify(input).slice(0, 200)
}
function extractQuestionText(input: unknown): string {
if (!input) return 'A question needs your answer'
const obj = input as Record<string, unknown>
if (Array.isArray(obj.questions) && obj.questions.length > 0) {
const q = obj.questions[0]
if (typeof q === 'object' && q && 'question' in q) return (q as any).question
}
if (typeof obj.question === 'string') return obj.question
return 'A question needs your answer'
}
function truncate(str: string, max: number): string {
if (str.length <= max) return str
return str.slice(0, max - 1) + '\u2026'
}

View File

@@ -10,7 +10,7 @@
"start:frontend": "cd frontend && bun run dev --host",
"tauri": "npx --prefix frontend tauri",
"tauri:dev": "npx --prefix frontend tauri dev",
"tauri:build": "npx --prefix frontend tauri build",
"tauri:build": "frontend/node_modules/.bin/tauri build",
"tauri:android:init": "npx --prefix frontend tauri android init",
"tauri:android:build": "npx --prefix frontend tauri android build",
"build:android:tauri": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",

View File

@@ -7,6 +7,7 @@ initDatabase()
// Start HTTP API server
Bun.serve({
hostname: '0.0.0.0',
port: PORT_HTTP,
fetch: handleRequest
})

View File

@@ -105,11 +105,14 @@ export function handleTranscriptDebugRaw(sessionId: string, url: URL): Response
return errorResponse(`Session ${sessionId} not found`, 404)
}
const stat = statSync(filePath)
const content = readFileSync(filePath, 'utf-8')
return new Response(content, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': String(stat.size),
'X-File-Size': String(stat.size),
...corsHeaders
}
})

View File

@@ -33,6 +33,7 @@ export function getClients() {
export function startSyncServer() {
const server = Bun.serve({
hostname: '0.0.0.0',
port: PORT_GIT,
async fetch(req, server) {
const url = new URL(req.url)

41
src-tauri/Cargo.lock generated
View File

@@ -18,6 +18,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-store",
@@ -1487,6 +1488,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -4097,6 +4116,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -4266,6 +4286,21 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.7"
@@ -6009,6 +6044,12 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.1"

View File

@@ -13,11 +13,12 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-http = "2"
tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default permissions for Agent UI",
"windows": ["main", "pip-terminal"],
"windows": ["main", "pip-terminal", "pip-terminal-1", "pip-terminal-2", "pip-terminal-3", "pip-terminal-4", "pip-terminal-5", "approval-window", "loading-window"],
"permissions": [
"core:default",
{
@@ -28,9 +28,21 @@
"core:window:allow-set-position",
"core:window:allow-set-focus",
"core:window:allow-set-decorations",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-inner-position",
"core:window:allow-inner-size",
"core:window:allow-outer-position",
"core:window:allow-outer-size",
"core:webview:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-webview-close",
"core:window:allow-destroy"
"core:window:allow-destroy",
"global-shortcut:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
"global-shortcut:allow-unregister-all",
"core:tray:default"
]
}

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@@ -1,3 +1,46 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn open_pip_terminal(app: &tauri::AppHandle, idx: u8) {
let label = format!("pip-terminal-{}", idx);
// If PiP already exists, just focus it
if let Some(win) = app.get_webview_window(&label) {
let _ = win.set_focus();
return;
}
// Create new PiP window — the page itself handles terminal connection or showing "new session" modal
let url = format!("/transcript-debug/{}?pip=1", idx);
let x = 1520.0_f64; // sensible default, will be near right edge on 1920px screens
let y = 60.0 + (idx as f64 - 1.0) * 40.0;
let _ = WebviewWindowBuilder::new(
app,
&label,
tauri::WebviewUrl::App(url.into()),
)
.title(&format!("T{} - Agent UI", idx))
.inner_size(380.0, 620.0)
.position(x, y)
.decorations(false)
.resizable(true)
.focused(true)
.build();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -6,6 +49,96 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state != ShortcutState::Pressed {
return;
}
let mods = shortcut.mods;
let key = shortcut.key;
// Ctrl+Alt+E → show main window
if mods == Modifiers::CONTROL | Modifiers::ALT && key == Code::KeyE {
show_main_window(app);
return;
}
// Ctrl+1-5 → open PiP terminal
if mods == Modifiers::CONTROL {
match key {
Code::Digit1 => open_pip_terminal(app, 1),
Code::Digit2 => open_pip_terminal(app, 2),
Code::Digit3 => open_pip_terminal(app, 3),
Code::Digit4 => open_pip_terminal(app, 4),
Code::Digit5 => open_pip_terminal(app, 5),
_ => {}
}
}
})
.build(),
)
.setup(|app| {
// Register global shortcuts (desktop only)
#[cfg(desktop)]
{
let ctrl_alt = Modifiers::CONTROL | Modifiers::ALT;
let ctrl = Modifiers::CONTROL;
let shortcuts = [
Shortcut::new(Some(ctrl_alt), Code::KeyE),
Shortcut::new(Some(ctrl), Code::Digit1),
Shortcut::new(Some(ctrl), Code::Digit2),
Shortcut::new(Some(ctrl), Code::Digit3),
Shortcut::new(Some(ctrl), Code::Digit4),
Shortcut::new(Some(ctrl), Code::Digit5),
];
for s in &shortcuts {
let _ = app.global_shortcut().register(*s);
}
}
// Build system tray (desktop only)
#[cfg(desktop)]
{
let show_item = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
}
Ok(())
})
.on_window_event(|window, event| {
// Hide main window to tray instead of closing (desktop only)
#[cfg(desktop)]
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
api.prevent_close();
let _ = window.hide();
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}