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:
2026-02-13 18:00:54 -06:00
parent 4450d1e034
commit 3c57f95b90
17 changed files with 1438 additions and 282 deletions

View File

@@ -2,10 +2,21 @@
<html lang="es">
<head>
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="description" content="Dynamic canvas for Claude Code interaction" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Agent UI</title>
</head>
<body>

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"predev": "npm install @nucleoriofrio/webmcp@git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git --silent",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js"
},
"dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
@@ -23,6 +24,7 @@
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"sharp": "^0.34.5",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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

View 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)

View File

@@ -4,7 +4,10 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
import StatusBar from './components/StatusBar.vue'
import Toolbar from './components/Toolbar.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 PwaInstallBanner from './components/PwaInstallBanner.vue'
import { initWebMCP, getWebMCP, startTokenPolling, stopTokenPolling, connectWithToken } from './services/webmcp'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { useCanvasStore } from './stores/canvas'
@@ -57,7 +60,10 @@ watch(() => route.name, (newPage) => {
<header class="app-header">
<div class="header-left">
<h1 class="logo">Agent UI</h1>
<ConnectionDropdown />
<ComponentsDropdown />
<ToolsDropdown />
<PwaInstallBanner />
</div>
<StatusBar />
</header>
@@ -97,6 +103,7 @@ watch(() => route.name, (newPage) => {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
background: var(--bg-primary);
}
@@ -105,8 +112,12 @@ watch(() => route.name, (newPage) => {
align-items: center;
justify-content: space-between;
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);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.header-left {

View File

@@ -19,17 +19,154 @@ const isOpen = computed({
})
const terminalContainer = ref<HTMLElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const sessionId = ref<string | null>(null)
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 fitAddon: FitAddon | null = null
let socket: WebSocket | 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() {
if (!terminalContainer.value || terminal) return
@@ -37,30 +174,30 @@ function initTerminal() {
terminal = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
fontSize: 12,
fontFamily: "'Consolas', 'Lucida Console', monospace",
theme: {
background: '#0f0f14',
foreground: '#e4e4e7',
cursor: '#6366f1',
cursorAccent: '#0f0f14',
selectionBackground: 'rgba(99, 102, 241, 0.3)',
black: '#16161d',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
blue: '#3b82f6',
magenta: '#a855f7',
cyan: '#06b6d4',
white: '#e4e4e7',
brightBlack: '#52525b',
brightRed: '#f87171',
brightGreen: '#4ade80',
brightYellow: '#facc15',
brightBlue: '#60a5fa',
brightMagenta: '#c084fc',
brightCyan: '#22d3ee',
brightWhite: '#ffffff'
background: 'rgba(12, 12, 12, 0.95)',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(100, 150, 255, 0.4)',
black: '#0c0c0c',
red: '#c50f1f',
green: '#13a10e',
yellow: '#c19c00',
blue: '#0037da',
magenta: '#881798',
cyan: '#3a96dd',
white: '#cccccc',
brightBlack: '#767676',
brightRed: '#e74856',
brightGreen: '#16c60c',
brightYellow: '#f9f1a5',
brightBlue: '#3b78ff',
brightMagenta: '#b4009e',
brightCyan: '#61d6d6',
brightWhite: '#f2f2f2'
},
allowProposedApi: true
})
@@ -68,12 +205,9 @@ function initTerminal() {
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(terminalContainer.value)
nextTick(() => {
fitAddon?.fit()
})
nextTick(() => fitAddon?.fit())
resizeObserver = new ResizeObserver(() => {
if (fitAddon && terminal && !isMinimized.value) {
@@ -98,7 +232,6 @@ function initTerminal() {
async function connect() {
if (connecting.value || connected.value) return
connecting.value = true
try {
@@ -108,7 +241,6 @@ async function connect() {
connected.value = true
connecting.value = false
terminal?.focus()
if (terminal) {
socket?.send(JSON.stringify({
type: 'resize',
@@ -123,7 +255,7 @@ async function connect() {
if (msg.type === 'connected') {
sessionId.value = msg.sessionId
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') {
terminal?.write(msg.data)
@@ -150,14 +282,6 @@ async function connect() {
}
}
function disconnect() {
if (socket) {
socket.close()
socket = null
}
connected.value = false
}
function toggleMinimize() {
isMinimized.value = !isMinimized.value
if (!isMinimized.value) {
@@ -178,16 +302,12 @@ function runClaude() {
}
}
// Watch for open state
watch(isOpen, async (open) => {
if (open) {
isMinimized.value = false
await nextTick()
if (!terminal) {
initTerminal()
}
if (!connected.value && !connecting.value) {
connect()
}
if (!terminal) initTerminal()
if (!connected.value && !connecting.value) connect()
nextTick(() => {
fitAddon?.fit()
terminal?.focus()
@@ -207,46 +327,65 @@ onBeforeUnmount(() => {
resizeObserver?.disconnect()
socket?.close()
terminal?.dispose()
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<template>
<Teleport to="body">
<Transition name="terminal-slide">
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
<!-- Header -->
<div class="terminal-header" @dblclick="toggleMinimize">
<div class="header-left">
<div class="traffic-lights">
<button class="light red" @click="close" title="Close"></button>
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
<button class="light green" @click="runClaude" title="Run Claude"></button>
<!-- Minimized -->
<Transition name="fab-pop">
<button
v-if="isOpen && isMinimized"
ref="fabRef"
class="aero-btn"
:class="{ dragging: isDragging }"
:style="minimizedStyle"
@mousedown="startDrag($event, true)"
>
<svg width="12" height="12" 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>
</button>
</Transition>
<!-- Window -->
<Transition name="win-slide">
<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 class="window-controls">
<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>
<button @click="toggleMinimize" title="Minimize"><svg width="8" height="1"><rect width="8" height="1" fill="currentColor"/></svg></button>
<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>
<span class="terminal-title">
Terminal
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
</span>
</div>
<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>
<!-- Content -->
<div class="content">
<div ref="terminalContainer" class="term"></div>
</div>
</div>
<!-- Terminal body -->
<div v-show="!isMinimized" class="terminal-body">
<div ref="terminalContainer" class="terminal-container"></div>
</div>
<!-- Minimized bar -->
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
<span>Click to expand</span>
<!-- Resize handle -->
<div class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
@@ -254,237 +393,181 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.floating-terminal {
.aero-btn {
position: fixed;
bottom: 20px;
right: 20px;
width: 700px;
height: 450px;
background: #0f0f14;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(16px);
-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;
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;
z-index: 9999;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.floating-terminal.minimized {
height: auto;
}
/* Header */
.terminal-header {
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #16161d;
border-bottom: 1px solid #2a2a3a;
cursor: default;
height: 22px;
padding: 0 2px 0 6px;
background: rgba(255,255,255,0.25);
border-bottom: 1px solid rgba(255,255,255,0.3);
cursor: grab;
user-select: none;
}
.aero-win.dragging .titlebar { cursor: grabbing; }
.header-left {
.left {
display: flex;
align-items: center;
gap: 12px;
gap: 5px;
color: #222;
font: 500 10px/1 system-ui, sans-serif;
}
.traffic-lights {
display: flex;
gap: 8px;
}
.light {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
.link {
margin-left: 2px;
color: #369;
font-size: 9px;
text-decoration: underline;
cursor: pointer;
transition: opacity 0.15s;
}
.link:hover { color: #47a; }
.light:hover {
opacity: 0.8;
.window-controls {
display: flex;
gap: 1px;
}
.light.red {
background: #ef4444;
}
.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 {
.window-controls button {
width: 20px;
height: 16px;
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 8px;
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;
justify-content: center;
background: rgba(255,255,255,0.3);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 2px;
color: #333;
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) {
background: #818cf8;
}
.btn-connect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Terminal body */
.terminal-body {
.content {
flex: 1;
margin: 2px;
border-radius: 2px;
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%;
height: 100%;
}
.terminal-container :deep(.xterm) {
.term :deep(.xterm) {
height: 100%;
padding: 2px;
}
.terminal-container :deep(.xterm-viewport) {
.term :deep(.xterm-viewport) {
overflow-y: auto !important;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
.term :deep(.xterm-viewport::-webkit-scrollbar) {
width: 8px;
background: rgba(0,0,0,0.2);
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
background: transparent;
}
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: #2a2a3a;
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(255,255,255,0.15);
border-radius: 4px;
}
/* Minimized bar */
.minimized-bar {
padding: 8px 16px;
text-align: center;
color: #52525b;
font-size: 12px;
cursor: pointer;
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(255,255,255,0.25);
}
.minimized-bar:hover {
color: #a1a1aa;
}
.fab-pop-enter-active, .fab-pop-leave-active { transition: all .12s ease; }
.fab-pop-enter-from, .fab-pop-leave-to { opacity: 0; transform: scale(0.9); }
/* Transition */
.terminal-slide-enter-active,
.terminal-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
.terminal-slide-enter-from,
.terminal-slide-leave-to {
opacity: 0;
transform: translateY(100px) scale(0.95);
}
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
/* Mobile responsive */
@media (max-width: 768px) {
.floating-terminal {
bottom: 0;
right: 0;
left: 0;
@media (max-width: 640px) {
.aero-win {
inset: auto 0 0 0 !important;
width: 100%;
height: 100%;
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;
height: 55%;
}
.glass { border-radius: 6px 6px 0 0; }
.aero-btn { bottom: 12px !important; right: 12px !important; left: auto !important; top: auto !important; }
}
</style>

View 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>

View File

@@ -17,7 +17,7 @@ let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
const WS_URL = 'ws://localhost:4103'
const WS_URL = `ws://${window.location.hostname}:4103`
function initTerminal() {
if (!terminalContainer.value) return

View File

@@ -18,15 +18,20 @@
html, body {
height: 100%;
min-height: 100vh;
min-height: 100dvh;
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);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
}
#app {
height: 100%;
min-height: 100vh;
min-height: 100dvh;
}
/* Scrollbar styling */

View File

@@ -18,15 +18,36 @@ export default defineConfig({
vue(),
VitePWA({
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: {
name: 'Agent UI - Dynamic Canvas',
name: 'Agent UI',
short_name: 'AgentUI',
description: 'Dynamic canvas for Claude Code interaction',
theme_color: '#1a1a2e',
background_color: '#1a1a2e',
description: 'Dynamic canvas for Claude Code interaction via WebMCP',
theme_color: '#16161d',
background_color: '#0f0f14',
display: 'standalone',
orientation: 'any',
start_url: '/',
scope: '/',
categories: ['developer', 'utilities'],
icons: [
{
src: 'icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any'
},
{
src: 'icons/icon-192.png',
sizes: '192x192',
@@ -36,6 +57,12 @@ export default defineConfig({
src: 'icons/icon-512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
}
@@ -44,6 +71,8 @@ export default defineConfig({
server: {
port: 4100,
host: true,
allowedHosts: ['z590.interno.com', 'localhost'],
cors: true,
proxy: {
'/api': 'http://localhost:4101'
},