feat: Add PWA support and CORS configuration
- Configure VitePWA with manifest, icons and service worker - Add PwaInstallBanner component for install prompt in header - Enable CORS for z590.interno.com access - Use dynamic hostname for terminal WebSocket connection - Add generate-icons script with sharp for PWA icons - Fix theme-color to match header background
This commit is contained in:
@@ -2,10 +2,21 @@
|
|||||||
<html lang="es">
|
<html lang="es">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#16161d" />
|
||||||
|
<meta name="description" content="Dynamic canvas for Claude Code interaction via WebMCP" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Agent UI" />
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
|
||||||
<meta name="theme-color" content="#1a1a2e" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<meta name="description" content="Dynamic canvas for Claude Code interaction" />
|
|
||||||
<title>Agent UI</title>
|
<title>Agent UI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
739
frontend/package-lock.json
generated
739
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@
|
|||||||
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
|
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"generate-icons": "node scripts/generate-icons.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vue-tsc": "^3.1.5"
|
"vue-tsc": "^3.1.5"
|
||||||
|
|||||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
18
frontend/public/icons/icon.svg
Normal file
18
frontend/public/icons/icon.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea"/>
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4facfe"/>
|
||||||
|
<stop offset="100%" style="stop-color:#00f2fe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<circle cx="256" cy="200" r="80" fill="none" stroke="white" stroke-width="16" opacity="0.9"/>
|
||||||
|
<circle cx="256" cy="200" r="40" fill="url(#accent)"/>
|
||||||
|
<rect x="156" y="320" width="200" height="24" rx="12" fill="white" opacity="0.8"/>
|
||||||
|
<rect x="186" y="360" width="140" height="16" rx="8" fill="white" opacity="0.5"/>
|
||||||
|
<rect x="206" y="392" width="100" height="16" rx="8" fill="white" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 948 B |
43
frontend/scripts/generate-icons.js
Normal file
43
frontend/scripts/generate-icons.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import sharp from 'sharp'
|
||||||
|
import { readFileSync, mkdirSync } from 'fs'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const iconsDir = join(__dirname, '../public/icons')
|
||||||
|
|
||||||
|
// Read SVG
|
||||||
|
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'))
|
||||||
|
|
||||||
|
// Generate icons
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'icon-192.png', size: 192 },
|
||||||
|
{ name: 'icon-512.png', size: 512 },
|
||||||
|
{ name: 'icon-maskable-512.png', size: 512 }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
for (const { name, size } of sizes) {
|
||||||
|
await sharp(svgBuffer)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toFile(join(iconsDir, name))
|
||||||
|
console.log(`Generated ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also generate apple-touch-icon
|
||||||
|
await sharp(svgBuffer)
|
||||||
|
.resize(180, 180)
|
||||||
|
.png()
|
||||||
|
.toFile(join(iconsDir, 'apple-touch-icon.png'))
|
||||||
|
console.log('Generated apple-touch-icon.png')
|
||||||
|
|
||||||
|
// Favicon
|
||||||
|
await sharp(svgBuffer)
|
||||||
|
.resize(32, 32)
|
||||||
|
.png()
|
||||||
|
.toFile(join(__dirname, '../public/favicon.png'))
|
||||||
|
console.log('Generated favicon.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
generate().catch(console.error)
|
||||||
@@ -4,7 +4,10 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
|
|||||||
import StatusBar from './components/StatusBar.vue'
|
import StatusBar from './components/StatusBar.vue'
|
||||||
import Toolbar from './components/Toolbar.vue'
|
import Toolbar from './components/Toolbar.vue'
|
||||||
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||||
|
import ToolsDropdown from './components/ToolsDropdown.vue'
|
||||||
|
import ConnectionDropdown from './components/ConnectionDropdown.vue'
|
||||||
import FloatingTerminal from './components/FloatingTerminal.vue'
|
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||||
|
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||||
import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp'
|
import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp'
|
||||||
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
|
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
|
||||||
import { useCanvasStore } from './stores/canvas'
|
import { useCanvasStore } from './stores/canvas'
|
||||||
@@ -57,7 +60,10 @@ watch(() => route.name, (newPage) => {
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="logo">Agent UI</h1>
|
<h1 class="logo">Agent UI</h1>
|
||||||
|
<ConnectionDropdown />
|
||||||
<ComponentsDropdown />
|
<ComponentsDropdown />
|
||||||
|
<ToolsDropdown />
|
||||||
|
<PwaInstallBanner />
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</header>
|
</header>
|
||||||
@@ -97,6 +103,7 @@ watch(() => route.name, (newPage) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +112,12 @@ watch(() => route.name, (newPage) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
padding-top: calc(0.75rem + env(safe-area-inset-top, 0px));
|
||||||
|
padding-left: calc(1.5rem + env(safe-area-inset-left, 0px));
|
||||||
|
padding-right: calc(1.5rem + env(safe-area-inset-right, 0px));
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
|
|||||||
@@ -19,17 +19,154 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const terminalContainer = ref<HTMLElement | null>(null)
|
const terminalContainer = ref<HTMLElement | null>(null)
|
||||||
|
const terminalRef = ref<HTMLElement | null>(null)
|
||||||
const connected = ref(false)
|
const connected = ref(false)
|
||||||
const connecting = ref(false)
|
const connecting = ref(false)
|
||||||
const sessionId = ref<string | null>(null)
|
const sessionId = ref<string | null>(null)
|
||||||
const isMinimized = ref(false)
|
const isMinimized = ref(false)
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const position = ref({ x: 0, y: 0 })
|
||||||
|
const dragOffset = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
// Resize state
|
||||||
|
const isResizing = ref(false)
|
||||||
|
const size = ref({ w: 580, h: 360 })
|
||||||
|
|
||||||
let terminal: Terminal | null = null
|
let terminal: Terminal | null = null
|
||||||
let fitAddon: FitAddon | null = null
|
let fitAddon: FitAddon | null = null
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
const WS_URL = 'ws://localhost:4103'
|
const WS_URL = `ws://${window.location.hostname}:4103`
|
||||||
|
|
||||||
|
const fabRef = ref<HTMLElement | null>(null)
|
||||||
|
const dragStartTime = ref(0)
|
||||||
|
const dragStartPos = ref({ x: 0, y: 0 })
|
||||||
|
const isDraggingFab = ref(false)
|
||||||
|
|
||||||
|
function startDrag(e: MouseEvent, isFab = false) {
|
||||||
|
if (!isFab && (e.target as HTMLElement).closest('.window-controls')) return
|
||||||
|
|
||||||
|
isDragging.value = true
|
||||||
|
isDraggingFab.value = isFab
|
||||||
|
dragStartTime.value = Date.now()
|
||||||
|
dragStartPos.value = { x: e.clientX, y: e.clientY }
|
||||||
|
|
||||||
|
const element = isFab ? fabRef.value : terminalRef.value
|
||||||
|
const rect = element?.getBoundingClientRect()
|
||||||
|
if (rect) {
|
||||||
|
dragOffset.value = {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onDrag)
|
||||||
|
document.addEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrag(e: MouseEvent) {
|
||||||
|
if (!isDragging.value) return
|
||||||
|
|
||||||
|
const newX = e.clientX - dragOffset.value.x
|
||||||
|
const newY = e.clientY - dragOffset.value.y
|
||||||
|
|
||||||
|
const elementWidth = isDraggingFab.value ? 120 : (terminalRef.value?.offsetWidth || 580)
|
||||||
|
const elementHeight = isDraggingFab.value ? 28 : (terminalRef.value?.offsetHeight || 360)
|
||||||
|
const maxX = window.innerWidth - elementWidth
|
||||||
|
const maxY = window.innerHeight - elementHeight
|
||||||
|
|
||||||
|
position.value = {
|
||||||
|
x: Math.max(0, Math.min(newX, maxX)),
|
||||||
|
y: Math.max(0, Math.min(newY, maxY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag(e: MouseEvent) {
|
||||||
|
const wasDraggingFab = isDraggingFab.value
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
|
||||||
|
if (wasDraggingFab) {
|
||||||
|
const elapsed = Date.now() - dragStartTime.value
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(e.clientX - dragStartPos.value.x, 2) +
|
||||||
|
Math.pow(e.clientY - dragStartPos.value.y, 2)
|
||||||
|
)
|
||||||
|
if (elapsed < 200 && distance < 5) {
|
||||||
|
toggleMinimize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDraggingFab.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize functions
|
||||||
|
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
isResizing.value = true
|
||||||
|
resizeStart.value = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
w: size.value.w,
|
||||||
|
h: size.value.h
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onResize)
|
||||||
|
document.addEventListener('mouseup', stopResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(e: MouseEvent) {
|
||||||
|
if (!isResizing.value) return
|
||||||
|
|
||||||
|
const deltaX = e.clientX - resizeStart.value.x
|
||||||
|
const deltaY = e.clientY - resizeStart.value.y
|
||||||
|
|
||||||
|
size.value = {
|
||||||
|
w: Math.max(400, Math.min(resizeStart.value.w + deltaX, window.innerWidth - 40)),
|
||||||
|
h: Math.max(250, Math.min(resizeStart.value.h + deltaY, window.innerHeight - 40))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopResize() {
|
||||||
|
isResizing.value = false
|
||||||
|
document.removeEventListener('mousemove', onResize)
|
||||||
|
document.removeEventListener('mouseup', stopResize)
|
||||||
|
nextTick(() => fitAddon?.fit())
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalStyle = computed(() => {
|
||||||
|
const base = {
|
||||||
|
width: `${size.value.w}px`,
|
||||||
|
height: `${size.value.h}px`
|
||||||
|
}
|
||||||
|
if (position.value.x === 0 && position.value.y === 0) {
|
||||||
|
return { ...base, bottom: '16px', right: '16px' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
top: `${position.value.y}px`,
|
||||||
|
left: `${position.value.x}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
right: 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const minimizedStyle = computed(() => {
|
||||||
|
if (position.value.x === 0 && position.value.y === 0) {
|
||||||
|
return { bottom: '16px', right: '16px' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: `${position.value.y}px`,
|
||||||
|
left: `${position.value.x}px`,
|
||||||
|
bottom: 'auto',
|
||||||
|
right: 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function initTerminal() {
|
function initTerminal() {
|
||||||
if (!terminalContainer.value || terminal) return
|
if (!terminalContainer.value || terminal) return
|
||||||
@@ -37,30 +174,30 @@ function initTerminal() {
|
|||||||
terminal = new Terminal({
|
terminal = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
fontFamily: "'Consolas', 'Lucida Console', monospace",
|
||||||
theme: {
|
theme: {
|
||||||
background: '#0f0f14',
|
background: 'rgba(12, 12, 12, 0.95)',
|
||||||
foreground: '#e4e4e7',
|
foreground: '#ffffff',
|
||||||
cursor: '#6366f1',
|
cursor: '#ffffff',
|
||||||
cursorAccent: '#0f0f14',
|
cursorAccent: '#000000',
|
||||||
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
selectionBackground: 'rgba(100, 150, 255, 0.4)',
|
||||||
black: '#16161d',
|
black: '#0c0c0c',
|
||||||
red: '#ef4444',
|
red: '#c50f1f',
|
||||||
green: '#22c55e',
|
green: '#13a10e',
|
||||||
yellow: '#eab308',
|
yellow: '#c19c00',
|
||||||
blue: '#3b82f6',
|
blue: '#0037da',
|
||||||
magenta: '#a855f7',
|
magenta: '#881798',
|
||||||
cyan: '#06b6d4',
|
cyan: '#3a96dd',
|
||||||
white: '#e4e4e7',
|
white: '#cccccc',
|
||||||
brightBlack: '#52525b',
|
brightBlack: '#767676',
|
||||||
brightRed: '#f87171',
|
brightRed: '#e74856',
|
||||||
brightGreen: '#4ade80',
|
brightGreen: '#16c60c',
|
||||||
brightYellow: '#facc15',
|
brightYellow: '#f9f1a5',
|
||||||
brightBlue: '#60a5fa',
|
brightBlue: '#3b78ff',
|
||||||
brightMagenta: '#c084fc',
|
brightMagenta: '#b4009e',
|
||||||
brightCyan: '#22d3ee',
|
brightCyan: '#61d6d6',
|
||||||
brightWhite: '#ffffff'
|
brightWhite: '#f2f2f2'
|
||||||
},
|
},
|
||||||
allowProposedApi: true
|
allowProposedApi: true
|
||||||
})
|
})
|
||||||
@@ -68,12 +205,9 @@ function initTerminal() {
|
|||||||
fitAddon = new FitAddon()
|
fitAddon = new FitAddon()
|
||||||
terminal.loadAddon(fitAddon)
|
terminal.loadAddon(fitAddon)
|
||||||
terminal.loadAddon(new WebLinksAddon())
|
terminal.loadAddon(new WebLinksAddon())
|
||||||
|
|
||||||
terminal.open(terminalContainer.value)
|
terminal.open(terminalContainer.value)
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => fitAddon?.fit())
|
||||||
fitAddon?.fit()
|
|
||||||
})
|
|
||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
if (fitAddon && terminal && !isMinimized.value) {
|
if (fitAddon && terminal && !isMinimized.value) {
|
||||||
@@ -98,7 +232,6 @@ function initTerminal() {
|
|||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
if (connecting.value || connected.value) return
|
if (connecting.value || connected.value) return
|
||||||
|
|
||||||
connecting.value = true
|
connecting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -108,7 +241,6 @@ async function connect() {
|
|||||||
connected.value = true
|
connected.value = true
|
||||||
connecting.value = false
|
connecting.value = false
|
||||||
terminal?.focus()
|
terminal?.focus()
|
||||||
|
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
socket?.send(JSON.stringify({
|
socket?.send(JSON.stringify({
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
@@ -123,7 +255,7 @@ async function connect() {
|
|||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
sessionId.value = msg.sessionId
|
sessionId.value = msg.sessionId
|
||||||
if (!msg.isNew) {
|
if (!msg.isNew) {
|
||||||
terminal?.write('\x1b[36m[Reconnected to session]\x1b[0m\r\n')
|
terminal?.write('\x1b[36m[Reconnected]\x1b[0m\r\n')
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'replay') {
|
} else if (msg.type === 'replay') {
|
||||||
terminal?.write(msg.data)
|
terminal?.write(msg.data)
|
||||||
@@ -150,14 +282,6 @@ async function connect() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
|
||||||
if (socket) {
|
|
||||||
socket.close()
|
|
||||||
socket = null
|
|
||||||
}
|
|
||||||
connected.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMinimize() {
|
function toggleMinimize() {
|
||||||
isMinimized.value = !isMinimized.value
|
isMinimized.value = !isMinimized.value
|
||||||
if (!isMinimized.value) {
|
if (!isMinimized.value) {
|
||||||
@@ -178,16 +302,12 @@ function runClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for open state
|
|
||||||
watch(isOpen, async (open) => {
|
watch(isOpen, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
isMinimized.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (!terminal) {
|
if (!terminal) initTerminal()
|
||||||
initTerminal()
|
if (!connected.value && !connecting.value) connect()
|
||||||
}
|
|
||||||
if (!connected.value && !connecting.value) {
|
|
||||||
connect()
|
|
||||||
}
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
fitAddon?.fit()
|
fitAddon?.fit()
|
||||||
terminal?.focus()
|
terminal?.focus()
|
||||||
@@ -207,46 +327,65 @@ onBeforeUnmount(() => {
|
|||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
socket?.close()
|
socket?.close()
|
||||||
terminal?.dispose()
|
terminal?.dispose()
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
document.removeEventListener('mousemove', onResize)
|
||||||
|
document.removeEventListener('mouseup', stopResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="terminal-slide">
|
<!-- Minimized -->
|
||||||
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
|
<Transition name="fab-pop">
|
||||||
<!-- Header -->
|
<button
|
||||||
<div class="terminal-header" @dblclick="toggleMinimize">
|
v-if="isOpen && isMinimized"
|
||||||
<div class="header-left">
|
ref="fabRef"
|
||||||
<div class="traffic-lights">
|
class="aero-btn"
|
||||||
<button class="light red" @click="close" title="Close"></button>
|
:class="{ dragging: isDragging }"
|
||||||
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
|
:style="minimizedStyle"
|
||||||
<button class="light green" @click="runClaude" title="Run Claude"></button>
|
@mousedown="startDrag($event, true)"
|
||||||
</div>
|
>
|
||||||
<span class="terminal-title">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
Terminal
|
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||||
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
|
</svg>
|
||||||
</span>
|
<span>Terminal</span>
|
||||||
</div>
|
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
|
||||||
|
|
||||||
<div class="header-right">
|
|
||||||
<span v-if="connected" class="status-dot connected"></span>
|
|
||||||
<span v-else-if="connecting" class="status-dot connecting"></span>
|
|
||||||
<span v-else class="status-dot disconnected"></span>
|
|
||||||
|
|
||||||
<button v-if="!connected" class="btn-connect" @click="connect" :disabled="connecting">
|
|
||||||
Connect
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Terminal body -->
|
<!-- Window -->
|
||||||
<div v-show="!isMinimized" class="terminal-body">
|
<Transition name="win-slide">
|
||||||
<div ref="terminalContainer" class="terminal-container"></div>
|
<div
|
||||||
|
v-if="isOpen && !isMinimized"
|
||||||
|
ref="terminalRef"
|
||||||
|
class="aero-win"
|
||||||
|
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||||
|
:style="terminalStyle"
|
||||||
|
>
|
||||||
|
<div class="glass">
|
||||||
|
<!-- Titlebar -->
|
||||||
|
<div class="titlebar" @mousedown="startDrag($event, false)" @dblclick="toggleMinimize">
|
||||||
|
<div class="left">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||||
|
</svg>
|
||||||
|
<span>Terminal</span>
|
||||||
|
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
|
||||||
|
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="window-controls">
|
||||||
<!-- Minimized bar -->
|
<button @click="runClaude" title="Claude"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></button>
|
||||||
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
|
<button @click="toggleMinimize" title="Minimize"><svg width="8" height="1"><rect width="8" height="1" fill="currentColor"/></svg></button>
|
||||||
<span>Click to expand</span>
|
<button class="x" @click="close" title="Close"><svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content">
|
||||||
|
<div ref="terminalContainer" class="term"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Resize handle -->
|
||||||
|
<div class="resize-handle" @mousedown="startResize"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -254,237 +393,181 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.floating-terminal {
|
.aero-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
display: flex;
|
||||||
right: 20px;
|
align-items: center;
|
||||||
width: 700px;
|
gap: 5px;
|
||||||
height: 450px;
|
padding: 4px 10px;
|
||||||
background: #0f0f14;
|
background: rgba(255,255,255,0.25);
|
||||||
border-radius: 12px;
|
backdrop-filter: blur(16px);
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #111;
|
||||||
|
font: 500 10px/1 system-ui, sans-serif;
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.aero-btn:hover { background: rgba(255,255,255,0.4); }
|
||||||
|
.aero-btn.dragging { cursor: grabbing; }
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 5px; height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
.dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
|
||||||
|
.dot.wait { background: #a80; animation: pulse .8s infinite; }
|
||||||
|
|
||||||
|
.aero-win {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 400px;
|
||||||
|
min-height: 250px;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: rgba(200,215,235,0.35);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.6);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.6);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(80,120,180,0.25),
|
||||||
|
0 6px 24px rgba(0,0,0,0.25),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.6);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 9999;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-terminal.minimized {
|
.titlebar {
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.terminal-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
height: 22px;
|
||||||
background: #16161d;
|
padding: 0 2px 0 6px;
|
||||||
border-bottom: 1px solid #2a2a3a;
|
background: rgba(255,255,255,0.25);
|
||||||
cursor: default;
|
border-bottom: 1px solid rgba(255,255,255,0.3);
|
||||||
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.aero-win.dragging .titlebar { cursor: grabbing; }
|
||||||
|
|
||||||
.header-left {
|
.left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 5px;
|
||||||
|
color: #222;
|
||||||
|
font: 500 10px/1 system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-lights {
|
.link {
|
||||||
display: flex;
|
margin-left: 2px;
|
||||||
gap: 8px;
|
color: #369;
|
||||||
}
|
font-size: 9px;
|
||||||
|
text-decoration: underline;
|
||||||
.light {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
}
|
||||||
|
.link:hover { color: #47a; }
|
||||||
|
|
||||||
.light:hover {
|
.window-controls {
|
||||||
opacity: 0.8;
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
.window-controls button {
|
||||||
.light.red {
|
width: 20px;
|
||||||
background: #ef4444;
|
height: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.light.yellow {
|
|
||||||
background: #eab308;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light.green {
|
|
||||||
background: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #a1a1aa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-id {
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: rgba(99, 102, 241, 0.2);
|
|
||||||
color: #818cf8;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
}
|
background: rgba(255,255,255,0.3);
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
.status-dot {
|
border-radius: 2px;
|
||||||
width: 8px;
|
color: #333;
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connected {
|
|
||||||
background: #22c55e;
|
|
||||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.connecting {
|
|
||||||
background: #eab308;
|
|
||||||
animation: pulse 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.disconnected {
|
|
||||||
background: #52525b;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-connect {
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: #6366f1;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
}
|
||||||
|
.window-controls button:hover {
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
.window-controls button.x:hover {
|
||||||
|
background: linear-gradient(180deg, #e66 0%, #c33 100%);
|
||||||
|
border-color: #a22;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-connect:hover:not(:disabled) {
|
.content {
|
||||||
background: #818cf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-connect:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terminal body */
|
|
||||||
.terminal-body {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
margin: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 8px;
|
background: rgba(0,0,0,0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.1) 100%);
|
||||||
|
border-radius: 0 0 5px 0;
|
||||||
|
}
|
||||||
|
.resize-handle:hover {
|
||||||
|
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0.2) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.aero-win.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.aero-win.resizing .term {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.term :deep(.xterm) {
|
||||||
.terminal-container :deep(.xterm) {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
.term :deep(.xterm-viewport) {
|
||||||
.terminal-container :deep(.xterm-viewport) {
|
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
.term :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
background: rgba(255,255,255,0.15);
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
|
||||||
background: #2a2a3a;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||||
/* Minimized bar */
|
background: rgba(255,255,255,0.25);
|
||||||
.minimized-bar {
|
|
||||||
padding: 8px 16px;
|
|
||||||
text-align: center;
|
|
||||||
color: #52525b;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.minimized-bar:hover {
|
.fab-pop-enter-active, .fab-pop-leave-active { transition: all .12s ease; }
|
||||||
color: #a1a1aa;
|
.fab-pop-enter-from, .fab-pop-leave-to { opacity: 0; transform: scale(0.9); }
|
||||||
}
|
|
||||||
|
|
||||||
/* Transition */
|
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
|
||||||
.terminal-slide-enter-active,
|
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||||
.terminal-slide-leave-active {
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-slide-enter-from,
|
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||||
.terminal-slide-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(100px) scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
@media (max-width: 640px) {
|
||||||
@media (max-width: 768px) {
|
.aero-win {
|
||||||
.floating-terminal {
|
inset: auto 0 0 0 !important;
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 55%;
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-terminal.minimized {
|
|
||||||
top: auto;
|
|
||||||
bottom: 0;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-header {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-lights {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.light {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablet */
|
|
||||||
@media (min-width: 769px) and (max-width: 1024px) {
|
|
||||||
.floating-terminal {
|
|
||||||
width: 550px;
|
|
||||||
height: 400px;
|
|
||||||
}
|
}
|
||||||
|
.glass { border-radius: 6px 6px 0 0; }
|
||||||
|
.aero-btn { bottom: 12px !important; right: 12px !important; left: auto !important; top: auto !important; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
218
frontend/src/components/PwaInstallBanner.vue
Normal file
218
frontend/src/components/PwaInstallBanner.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPrompt = ref<BeforeInstallPromptEvent | null>(null)
|
||||||
|
const isInstalled = ref(false)
|
||||||
|
const isPwa = ref(false)
|
||||||
|
const dismissed = ref(false)
|
||||||
|
|
||||||
|
// Check if running as PWA (standalone mode)
|
||||||
|
const checkPwaMode = () => {
|
||||||
|
isPwa.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(window.navigator as any).standalone === true ||
|
||||||
|
document.referrer.includes('android-app://')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if app is already installed
|
||||||
|
const checkInstalled = () => {
|
||||||
|
if ('getInstalledRelatedApps' in navigator) {
|
||||||
|
(navigator as any).getInstalledRelatedApps().then((apps: any[]) => {
|
||||||
|
isInstalled.value = apps.length > 0
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeInstallPrompt = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
installPrompt.value = e as BeforeInstallPromptEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
isInstalled.value = true
|
||||||
|
installPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = async () => {
|
||||||
|
if (!installPrompt.value) return
|
||||||
|
|
||||||
|
await installPrompt.value.prompt()
|
||||||
|
const { outcome } = await installPrompt.value.userChoice
|
||||||
|
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
isInstalled.value = true
|
||||||
|
}
|
||||||
|
installPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
dismissed.value = true
|
||||||
|
sessionStorage.setItem('pwa-dismissed', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBanner = computed(() => {
|
||||||
|
if (isPwa.value) return false
|
||||||
|
if (dismissed.value) return false
|
||||||
|
return installPrompt.value !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkPwaMode()
|
||||||
|
checkInstalled()
|
||||||
|
|
||||||
|
// Check if dismissed this session
|
||||||
|
dismissed.value = sessionStorage.getItem('pwa-dismissed') === 'true'
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled)
|
||||||
|
|
||||||
|
// Listen for display mode changes
|
||||||
|
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPwaMode)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="banner">
|
||||||
|
<div v-if="showBanner" class="pwa-banner">
|
||||||
|
<div class="banner-content">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="banner-text">Instalar Agent UI</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn-install" @click="install">
|
||||||
|
Instalar
|
||||||
|
</button>
|
||||||
|
<button class="btn-dismiss" @click="dismiss" title="Cerrar">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pwa-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-install {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-install:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dismiss {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dismiss:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.banner-enter-active,
|
||||||
|
.banner-leave-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-enter-from,
|
||||||
|
.banner-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pwa-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 9990;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,7 +17,7 @@ let fitAddon: FitAddon | null = null
|
|||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
const WS_URL = 'ws://localhost:4103'
|
const WS_URL = `ws://${window.location.hostname}:4103`
|
||||||
|
|
||||||
function initTerminal() {
|
function initTerminal() {
|
||||||
if (!terminalContainer.value) return
|
if (!terminalContainer.value) return
|
||||||
|
|||||||
@@ -18,15 +18,20 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
|
|||||||
@@ -18,15 +18,36 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.ico', 'icons/*.png'],
|
includeAssets: ['favicon.svg', 'icons/*.svg', 'icons/*.png'],
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
|
suppressWarnings: true
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
// Don't cache API calls - let them go directly to network
|
||||||
|
runtimeCaching: []
|
||||||
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Agent UI - Dynamic Canvas',
|
name: 'Agent UI',
|
||||||
short_name: 'AgentUI',
|
short_name: 'AgentUI',
|
||||||
description: 'Dynamic canvas for Claude Code interaction',
|
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
|
||||||
theme_color: '#1a1a2e',
|
theme_color: '#16161d',
|
||||||
background_color: '#1a1a2e',
|
background_color: '#0f0f14',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
orientation: 'any',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
categories: ['developer', 'utilities'],
|
||||||
icons: [
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'icons/icon.svg',
|
||||||
|
sizes: 'any',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
src: 'icons/icon-192.png',
|
src: 'icons/icon-192.png',
|
||||||
sizes: '192x192',
|
sizes: '192x192',
|
||||||
@@ -36,6 +57,12 @@ export default defineConfig({
|
|||||||
src: 'icons/icon-512.png',
|
src: 'icons/icon-512.png',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png'
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'icons/icon-maskable-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -44,6 +71,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 4100,
|
port: 4100,
|
||||||
host: true,
|
host: true,
|
||||||
|
allowedHosts: ['z590.interno.com', 'localhost'],
|
||||||
|
cors: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:4101'
|
'/api': 'http://localhost:4101'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ export function startTerminalServer() {
|
|||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type'
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders })
|
||||||
|
}
|
||||||
|
|
||||||
// Health check with session info
|
// Health check with session info
|
||||||
if (url.pathname === '/health') {
|
if (url.pathname === '/health') {
|
||||||
const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({
|
const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({
|
||||||
@@ -93,7 +104,7 @@ export function startTerminalServer() {
|
|||||||
status: 'ok',
|
status: 'ok',
|
||||||
sessions: sessionsInfo,
|
sessions: sessionsInfo,
|
||||||
cwd: WORKING_DIR
|
cwd: WORKING_DIR
|
||||||
})
|
}, { headers: corsHeaders })
|
||||||
}
|
}
|
||||||
|
|
||||||
// List active sessions
|
// List active sessions
|
||||||
|
|||||||
Reference in New Issue
Block a user