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:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
frontend/public/loading.html
Normal file
42
frontend/public/loading.html
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
124
frontend/src/composables/useApprovalWindow.ts
Normal file
124
frontend/src/composables/useApprovalWindow.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
123
frontend/src/composables/usePipWindow.ts
Normal file
123
frontend/src/composables/usePipWindow.ts
Normal 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 }
|
||||
}
|
||||
52
frontend/src/lib/loadingWindow.ts
Normal file
52
frontend/src/lib/loadingWindow.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
274
frontend/src/pages/ApprovalPage.vue
Normal file
274
frontend/src/pages/ApprovalPage.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
360
frontend/src/services/approvalNotifications.ts
Normal file
360
frontend/src/services/approvalNotifications.ts
Normal 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'
|
||||
}
|
||||
Reference in New Issue
Block a user