feat: add embedded terminal (xterm.js) and playwright browser panel
Some checks are pending
CI / build-check-test (push) Waiting to run

- Fix empty state overlay covering chat input (bottom:130px offset)
- Fix suggestion chips click-through with corrected z-layering
- Fix handleSuggestion to use chatPanel.agentInterface.setInput()
- Add JaeTerminalPanel: xterm.js + WebSocket bash shell (port 7701)
- Add JaeBrowserPanel: Playwright chromium screenshots (port 7702)
- Add terminal/browser toggle buttons in header toolbar
- Add collapsible right panel with Terminal/Browser tabs
- Add server/terminal-server.mjs and server/browser-server.mjs
- Add npm run dev:all script (Vite + terminal + browser servers)
This commit is contained in:
JAE 2026-03-26 20:10:57 +00:00
parent 2b53445f4e
commit 97cef8b4d3
7 changed files with 465 additions and 22 deletions

62
package-lock.json generated
View file

@ -4524,6 +4524,16 @@
"alien-signals": "^2.0.6"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
},
"node_modules/@xterm/headless": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
@ -8012,6 +8022,47 @@
"pathe": "^2.0.1"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/points-on-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
@ -9950,15 +10001,24 @@
"@jaeswift/jae-web-ui": "file:../",
"@mariozechner/mini-lit": "^0.2.0",
"@tailwindcss/vite": "^4.1.17",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"diff2html": "^3.4.56",
"lit": "^3.3.1",
"lucide": "^0.544.0",
"mermaid": "^11.13.0"
"mermaid": "^11.13.0",
"playwright": "^1.58.2"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^7.1.6"
}
},
"packages/web-ui/node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
}
}
}

View file

@ -8,17 +8,24 @@
"build": "vite build",
"preview": "vite preview",
"check": "tsgo --noEmit",
"clean": "shx rm -rf dist"
"clean": "shx rm -rf dist",
"terminal-server": "node server/terminal-server.mjs",
"browser-server": "node server/browser-server.mjs",
"dev:all": "concurrently -k -n VITE,TERM,BROWSER -c cyan,green,magenta \"npm run dev\" \"npm run terminal-server\" \"npm run browser-server\""
},
"dependencies": {
"@jaeswift/jae-ai": "file:../../ai",
"@jaeswift/jae-web-ui": "file:../",
"@mariozechner/mini-lit": "^0.2.0",
"@tailwindcss/vite": "^4.1.17",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"diff2html": "^3.4.56",
"lit": "^3.3.1",
"lucide": "^0.544.0",
"mermaid": "^11.13.0"
"mermaid": "^11.13.0",
"playwright": "^1.58.2"
},
"devDependencies": {
"typescript": "^5.7.3",

View file

@ -0,0 +1,65 @@
import { WebSocketServer } from 'ws';
import { chromium } from 'playwright';
const PORT = 7702;
const wss = new WebSocketServer({ port: PORT });
console.log(`Browser WS server on ws://localhost:${PORT}`);
let browser = null;
async function getBrowser() {
if (!browser) browser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-setuid-sandbox'] });
return browser;
}
wss.on('connection', async (ws) => {
let context = null;
let page = null;
async function screenshot() {
if (!page) return;
try {
const buf = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
ws.send(JSON.stringify({ type: 'screenshot', data: buf.toString('base64'), url: page.url() }));
} catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); }
}
async function navigate(url) {
try {
if (!context) {
const b = await getBrowser();
context = await b.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
}
if (!url.startsWith('http')) url = 'https://' + url;
ws.send(JSON.stringify({ type: 'loading' }));
await page.goto(url, { timeout: 30000, waitUntil: 'domcontentloaded' });
await screenshot();
} catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); }
}
ws.on('message', async (msg) => {
try {
const m = JSON.parse(msg.toString());
if (m.type === 'navigate') await navigate(m.url);
if (m.type === 'screenshot') await screenshot();
if (m.type === 'click') {
if (page) { await page.mouse.click(m.x, m.y); await screenshot(); }
}
if (m.type === 'scroll') {
if (page) { await page.mouse.wheel(0, m.dy); await screenshot(); }
}
if (m.type === 'type') {
if (page) { await page.keyboard.type(m.text); await screenshot(); }
}
if (m.type === 'back') { if (page) { await page.goBack(); await screenshot(); } }
if (m.type === 'fwd') { if (page) { await page.goForward(); await screenshot(); } }
if (m.type === 'reload'){ if (page) { await page.reload(); await screenshot(); } }
} catch(e) { ws.send(JSON.stringify({ type: 'error', msg: String(e) })); }
});
ws.on('close', async () => { if (context) await context.close().catch(()=>{}); context = null; page = null; });
// Send welcome screenshot placeholder
ws.send(JSON.stringify({ type: 'ready' }));
});

View file

@ -0,0 +1,27 @@
import { WebSocketServer } from 'ws';
import { spawn } from 'child_process';
const PORT = 7701;
const wss = new WebSocketServer({ port: PORT });
console.log(`Terminal WS server on ws://localhost:${PORT}`);
wss.on('connection', (ws) => {
const shell = spawn('/bin/bash', [], {
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
cwd: process.env.HOME || '/root',
});
shell.stdout.on('data', (d) => { try { ws.send(JSON.stringify({ type:'data', data: d.toString('binary') })); } catch{} });
shell.stderr.on('data', (d) => { try { ws.send(JSON.stringify({ type:'data', data: d.toString('binary') })); } catch{} });
shell.on('close', (code) => { try { ws.send(JSON.stringify({ type:'exit', code })); ws.close(); } catch{} });
ws.on('message', (msg) => {
try {
const m = JSON.parse(msg.toString());
if (m.type === 'input') shell.stdin.write(m.data);
if (m.type === 'resize') { /* no node-pty resize without pty, best effort */ }
} catch{}
});
ws.on('close', () => { shell.kill(); });
});

View file

@ -0,0 +1,126 @@
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@customElement('jae-browser-panel')
export class JaeBrowserPanel extends LitElement {
@state() private url = '';
@state() private inputUrl = '';
@state() private screenshot = '';
@state() private loading = false;
@state() private connected = false;
@state() private error = '';
private ws: WebSocket | null = null;
private imgEl: HTMLImageElement | null = null;
createRenderRoot() { return this; }
override connectedCallback() {
super.connectedCallback();
this.style.display = 'flex';
this.style.flexDirection = 'column';
this.style.height = '100%';
this.style.minHeight = '0';
this.connect();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.ws?.close();
}
connect() {
this.ws = new WebSocket('ws://localhost:7702');
this.ws.onopen = () => { this.connected = true; this.requestUpdate(); };
this.ws.onclose = () => { this.connected = false; this.requestUpdate(); };
this.ws.onerror = () => { this.connected = false; this.error = 'Browser server not running.'; this.requestUpdate(); };
this.ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'screenshot') {
this.loading = false;
this.screenshot = `data:image/jpeg;base64,${m.data}`;
this.url = m.url;
this.inputUrl = m.url;
this.error = '';
this.requestUpdate();
}
if (m.type === 'loading') { this.loading = true; this.requestUpdate(); }
if (m.type === 'error') { this.loading = false; this.error = m.msg; this.requestUpdate(); }
};
}
navigate(url?: string) {
const target = url || this.inputUrl;
if (!target) return;
this.ws?.send(JSON.stringify({ type: 'navigate', url: target }));
this.loading = true;
this.requestUpdate();
}
private handleImgClick(e: MouseEvent) {
const img = e.currentTarget as HTMLImageElement;
const rect = img.getBoundingClientRect();
const scaleX = 1280 / rect.width;
const scaleY = 800 / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
this.ws?.send(JSON.stringify({ type: 'click', x, y }));
this.loading = true;
this.requestUpdate();
}
private handleScroll(e: WheelEvent) {
e.preventDefault();
this.ws?.send(JSON.stringify({ type: 'scroll', dy: e.deltaY }));
}
override render() {
return html`
<div class="flex items-center gap-1 px-2 py-1 border-b border-border shrink-0 bg-muted/30" style="height:40px">
<button class="p-1 rounded hover:bg-secondary" title="Back" @click=${() => this.ws?.send(JSON.stringify({ type:'back' }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="p-1 rounded hover:bg-secondary" title="Forward" @click=${() => this.ws?.send(JSON.stringify({ type:'fwd' }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<button class="p-1 rounded hover:bg-secondary" title="Reload" @click=${() => this.ws?.send(JSON.stringify({ type:'reload' }))}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<input
class="flex-1 text-xs border border-border rounded px-2 py-1 bg-background text-foreground outline-none focus:ring-1 focus:ring-ring"
type="text"
.value=${this.inputUrl}
@input=${(e: Event) => { this.inputUrl = (e.target as HTMLInputElement).value; }}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.navigate(); }}
placeholder="Enter URL and press Enter..."
/>
<div class="w-2 h-2 rounded-full ml-1 ${this.connected ? 'bg-green-500' : 'bg-red-500'}" title="${this.connected ? 'connected' : 'disconnected'}"></div>
</div>
<div class="flex-1 overflow-auto min-h-0 relative bg-white">
${this.loading ? html`
<div class="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div class="text-sm text-muted-foreground">Loading...</div>
</div>
` : html``}
${this.error ? html`
<div class="p-4 text-sm text-red-500">${this.error}</div>
` : html``}
${this.screenshot ? html`
<img
src=${this.screenshot}
class="w-full cursor-crosshair"
style="display:block"
alt="browser"
@click=${this.handleImgClick}
@wheel=${this.handleScroll}
/>
` : !this.error ? html`
<div class="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
<p class="text-sm">Enter a URL above to browse</p>
</div>
` : html``}
</div>
`;
}
}

View file

@ -0,0 +1,115 @@
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
@customElement('jae-terminal-panel')
export class JaeTerminalPanel extends LitElement {
@state() private connected = false;
@state() private connecting = false;
private term: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private ws: WebSocket | null = null;
private container: HTMLElement | null = null;
private resizeObs: ResizeObserver | null = null;
createRenderRoot() { return this; }
override connectedCallback() {
super.connectedCallback();
this.style.display = 'flex';
this.style.flexDirection = 'column';
this.style.height = '100%';
this.style.minHeight = '0';
}
override disconnectedCallback() {
super.disconnectedCallback();
this.destroyTerminal();
}
private destroyTerminal() {
this.resizeObs?.disconnect();
this.ws?.close();
this.term?.dispose();
this.term = null; this.ws = null;
}
async connect() {
if (this.connected || this.connecting) return;
this.connecting = true;
this.requestUpdate();
await this.updateComplete;
this.container = this.querySelector('#xterm-container') as HTMLElement;
if (!this.container) { this.connecting = false; return; }
const isDark = document.documentElement.classList.contains('dark');
this.term = new Terminal({
cursorBlink: true,
fontFamily: '"Fira Code", "Cascadia Code", monospace',
fontSize: 13,
theme: isDark
? { background: '#09090b', foreground: '#e4e4e7', cursor: '#a1a1aa' }
: { background: '#ffffff', foreground: '#18181b', cursor: '#52525b' },
});
this.fitAddon = new FitAddon();
this.term.loadAddon(this.fitAddon);
this.term.loadAddon(new WebLinksAddon());
this.term.open(this.container);
this.fitAddon.fit();
this.ws = new WebSocket('ws://localhost:7701');
this.ws.onopen = () => {
this.connected = true; this.connecting = false;
this.requestUpdate();
};
this.ws.onclose = () => {
this.connected = false; this.connecting = false;
this.term?.write('\r\n\x1b[31m[disconnected]\x1b[0m\r\n');
this.requestUpdate();
};
this.ws.onerror = () => {
this.connecting = false; this.connected = false;
this.term?.write('\r\n\x1b[31m[connection error - is terminal server running?]\x1b[0m\r\n');
this.requestUpdate();
};
this.ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'data') this.term?.write(m.data);
if (m.type === 'exit') this.term?.write(`\r\n\x1b[33m[process exited: ${m.code}]\x1b[0m\r\n`);
};
this.term.onData((d) => { this.ws?.send(JSON.stringify({ type: 'input', data: d })); });
this.resizeObs = new ResizeObserver(() => {
requestAnimationFrame(() => {
this.fitAddon?.fit();
if (this.term && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows }));
}
});
});
this.resizeObs.observe(this.container);
}
disconnect() { this.destroyTerminal(); this.connected = false; this.requesting = false; this.requestUpdate(); }
override render() {
return html`
<div class="flex items-center gap-2 px-2 py-1 border-b border-border shrink-0 bg-muted/30" style="height:34px">
<div class="w-2 h-2 rounded-full ${this.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-xs font-mono text-muted-foreground">bash</span>
<div class="flex-1"></div>
${!this.connected && !this.connecting
? html`<button class="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:opacity-90" @click=${() => this.connect()}>Connect</button>`
: html``}
${this.connecting ? html`<span class="text-xs text-muted-foreground">Connecting...</span>` : html``}
${this.connected ? html`<button class="text-xs px-2 py-0.5 rounded bg-secondary text-secondary-foreground hover:opacity-90" @click=${() => this.disconnect()}>Kill</button>` : html``}
</div>
<div id="xterm-container" class="flex-1 overflow-hidden" style="min-height:0;background:#09090b"></div>
`;
}
}

View file

@ -42,6 +42,11 @@ import "./components/utility-toggle.js";
import { JaeSessionSidebar } from "./components/session-sidebar.js";
import "./components/session-sidebar.js";
import { JaeTerminalPanel } from './components/terminal-panel.js';
import { JaeBrowserPanel } from './components/browser-panel.js';
import './components/terminal-panel.js';
import './components/browser-panel.js';
registerCustomMessageRenderers();
const settings = new SettingsStore();
@ -75,6 +80,9 @@ let currentSessionId: string | undefined;
let currentTitle = "";
let isEditingTitle = false;
let agent: Agent;
let rightPanel: 'none' | 'terminal' | 'browser' = 'none';
let terminalPanel: JaeTerminalPanel | null = null;
let browserPanel: JaeBrowserPanel | null = null;
let chatPanel: ChatPanel;
let agentUnsubscribe: (() => void) | undefined;
@ -267,11 +275,24 @@ const newSession = () => {
const handleSuggestion = (e: Event) => {
const text = (e as CustomEvent<string>).detail;
if (chatPanel && (chatPanel as any).agentInterface) {
(chatPanel as any).agentInterface.setInput(text);
if (!text) return;
// Try ChatPanel.agentInterface.setInput first
if (chatPanel?.agentInterface) {
chatPanel.agentInterface.setInput(text);
// Focus the textarea after injection
requestAnimationFrame(() => {
const ta = document.querySelector("message-editor textarea") as HTMLTextAreaElement
|| document.querySelector("textarea") as HTMLTextAreaElement;
if (ta) ta.focus();
});
} else {
const textarea = document.querySelector("textarea") as HTMLTextAreaElement;
if (textarea) { textarea.value = text; textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.focus(); }
const ta = document.querySelector("message-editor textarea") as HTMLTextAreaElement
|| document.querySelector("textarea") as HTMLTextAreaElement;
if (ta) {
ta.value = text;
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.focus();
}
}
};
@ -300,6 +321,8 @@ const renderApp = () => {
${Button({ variant: "ghost", size: "sm", children: html`<span class="text-xs font-mono px-1">&#x2318;K</span>`, onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
${utilityToggle}
<theme-toggle></theme-toggle>
${Button({ variant: "ghost", size: "sm", children: html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`, onClick: () => { rightPanel = rightPanel === 'terminal' ? 'none' : 'terminal'; renderApp(); if (rightPanel === 'terminal') requestAnimationFrame(() => { terminalPanel = document.querySelector('jae-terminal-panel') as JaeTerminalPanel; terminalPanel?.connect(); }); }, title: "Toggle Terminal" })}
${Button({ variant: "ghost", size: "sm", children: html`<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`, onClick: () => { rightPanel = rightPanel === 'browser' ? 'none' : 'browser'; renderApp(); }, title: "Toggle Browser" })}
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings" })}
</div>
</div>
@ -307,16 +330,36 @@ const renderApp = () => {
${sidebar}
<div class="flex flex-col flex-1 min-w-0 min-h-0 relative">
${!hasMessages ? html`
<div class="absolute inset-0 z-10 flex flex-col overflow-auto bg-background" @suggestion=${handleSuggestion}>
<div class="absolute inset-x-0 top-0 z-10 flex flex-col overflow-y-auto bg-background" style="bottom:130px" @suggestion=${handleSuggestion}>
<jae-empty-state></jae-empty-state>
</div>
` : html``}
<div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" style="${!hasMessages ? "visibility:hidden" : ""}">
<div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" >
${chatPanel}
</div>
</div>
${rightPanel !== 'none' ? html`
<div class="flex flex-col border-l border-border" style="width:480px;min-width:320px;max-width:600px">
<div class="flex items-center gap-1 px-2 shrink-0 border-b border-border bg-muted/20" style="height:36px">
<button class="text-xs px-2 py-1 rounded ${
rightPanel === 'terminal'
? 'bg-primary text-primary-foreground'
: 'hover:bg-secondary text-muted-foreground'
}" @click=${() => { rightPanel = 'terminal'; renderApp(); requestAnimationFrame(() => { if (!terminalPanel) terminalPanel = document.querySelector('jae-terminal-panel') as JaeTerminalPanel; terminalPanel?.connect(); }); }}>Terminal</button>
<button class="text-xs px-2 py-1 rounded ${
rightPanel === 'browser'
? 'bg-primary text-primary-foreground'
: 'hover:bg-secondary text-muted-foreground'
}" @click=${() => { rightPanel = 'browser'; renderApp(); }}>Browser</button>
<div class="flex-1"></div>
<button class="text-xs px-2 py-1 rounded hover:bg-secondary text-muted-foreground" @click=${() => { rightPanel = 'none'; renderApp(); }} title="Close panel"></button>
</div>
</div>`, app);
${rightPanel === 'terminal' ? html`<jae-terminal-panel class="flex-1 min-h-0"></jae-terminal-panel>` : html``}
${rightPanel === 'browser' ? html`<jae-browser-panel class="flex-1 min-h-0"></jae-browser-panel>` : html``}
</div>
` : html``}
</div>
`, app);
};
async function initApp() {