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'
console.log('[TestNotif] Desktop requestPermission:', perm)
}
} catch (e) {
console.warn('[TestNotif] Desktop permission check failed:', e)
granted = true // Desktop usually grants by default
}
}
console.log('[TestNotif] Permission granted:', granted)
if (!granted) {
const perm = await requestPermission()
granted = perm === 'granted'
console.error('[TestNotif] STOPPED: Permission denied')
return
}
if (granted) {
sendNotification({ title, body })
// ── 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.warn('[Notification] Tauri plugin failed:', 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,15 +507,17 @@ watch(isOpen, async (open) => {
function handleGlobalKeydown(e: KeyboardEvent) {
if (!e.ctrlKey) return
// Ctrl+1..5 → switch to terminal by index
const num = parseInt(e.key)
if (num >= 1 && num <= 5) {
const terminal = openTerminals.value[num - 1]
if (!terminal) return
e.preventDefault()
if (!isOpen.value) isOpen.value = true
switchToTerminal(terminal.sessionId)
return
// 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]
if (!terminal) return
e.preventDefault()
if (!isOpen.value) isOpen.value = true
switchToTerminal(terminal.sessionId)
return
}
}
// Zoom shortcuts (only when open)
@@ -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) {
modalVisible.value = true
// 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'
}