mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 04:59:41 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507423b776 | ||
|
|
67d0b0b538 | ||
|
|
26cfd8b7ef | ||
|
|
208e6bc175 | ||
|
|
6d52b16edc | ||
|
|
4988e85901 |
@@ -1,9 +1,16 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.3.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add tunnel status indicator with control panel — green pulsing dot in header when Cloudflare tunnel is active, dropdown with URL, remote clients, auth sessions, and start/stop/QR/revoke controls
|
||||
|
||||
## 0.3.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Operation Lightspeed: 5 parallel performance optimizations — multi-layer backpressure to prevent terminal write freezes, TERMINAL_TAIL_SIZE constant with client-drop recovery, tab switching SSE gating, and local echo improvements
|
||||
- Codebase cleanup: remove dead code (unused token validation exports, PlanPhase alias), add execPattern() regex helper to eliminate repetitive .lastIndex resets, centralize 11 magic number constants into config files, fix CLAUDE.md inaccuracies, and add 316 new tests for utilities, respawn helpers, and system-routes
|
||||
|
||||
## 0.3.6
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.3.7 (must match `package.json`)
|
||||
**Version**: 0.3.8 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -110,7 +110,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (12 route modules + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` ★ (~12.1K lines) + 9 JS modules (incl. `sw.js` service worker) | |
|
||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
||||
| **Types** | `src/types/index.ts` → 14 domain files | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||
|
||||
|
||||
@@ -143,6 +143,10 @@ Watch background agents work in real-time. Codeman monitors agent activity and d
|
||||
|
||||
## Zero-Lag Input Overlay
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/zerolag-demo.gif" alt="Zerolag Demo — local echo vs server echo side-by-side" width="900">
|
||||
</p>
|
||||
|
||||
When accessing your coding agent remotely (VPN, Tailscale, SSH tunnel), every keystroke normally takes 200-300ms to round-trip. Codeman implements a **Mosh-inspired local echo system** that makes typing feel instant regardless of latency.
|
||||
|
||||
A pixel-perfect DOM overlay inside xterm.js renders keystrokes at 0ms. Background forwarding silently sends every character to the PTY in 50ms debounced batches, so Tab completion, `Ctrl+R` history search, and all shell features work normally. When the server echo arrives 200-300ms later, the overlay seamlessly disappears and the real terminal text takes over — the transition is invisible.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 806 KiB |
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.3.7",
|
||||
"version": "0.3.8",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -51,8 +51,6 @@
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7",
|
||||
"chalk": "^5.3.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
@@ -93,6 +91,10 @@
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@remotion/compositor-linux-x64-gnu": "^4.0.432",
|
||||
"@rspack/binding-linux-x64-gnu": "^1.7.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
@@ -11,4 +11,5 @@ export interface EventPort {
|
||||
batchTerminalData(sessionId: string, data: string): void;
|
||||
broadcastSessionStateDebounced(sessionId: string): void;
|
||||
batchTaskUpdate(sessionId: string, task: BackgroundTask): void;
|
||||
getSseClientCount(): number;
|
||||
}
|
||||
|
||||
+224
-2
@@ -351,6 +351,9 @@ class CodemanApp {
|
||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
|
||||
// Tunnel indicator state
|
||||
this._tunnelUrl = null;
|
||||
|
||||
// Tab alert states: Map<sessionId, 'action' | 'idle'>
|
||||
this.tabAlerts = new Map();
|
||||
|
||||
@@ -519,6 +522,8 @@ class CodemanApp {
|
||||
});
|
||||
// Register service worker for push notifications
|
||||
this.registerServiceWorker();
|
||||
// Fetch tunnel status for header indicator (desktop only)
|
||||
this.loadTunnelStatus();
|
||||
// Share a single settings fetch between both consumers
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
@@ -2617,8 +2622,10 @@ class CodemanApp {
|
||||
// Tunnel
|
||||
_onTunnelStarted(data) {
|
||||
console.log('[Tunnel] Started:', data.url);
|
||||
this._tunnelUrl = data.url;
|
||||
this._dismissTunnelConnecting();
|
||||
this._updateTunnelUrlDisplay(data.url);
|
||||
this._updateTunnelIndicator(true);
|
||||
const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible');
|
||||
if (welcomeVisible) {
|
||||
// On welcome screen: QR appears inline, expanded first
|
||||
@@ -2634,9 +2641,12 @@ class CodemanApp {
|
||||
|
||||
_onTunnelStopped() {
|
||||
console.log('[Tunnel] Stopped');
|
||||
this._tunnelUrl = null;
|
||||
this._dismissTunnelConnecting();
|
||||
this._updateTunnelUrlDisplay(null);
|
||||
this._updateWelcomeTunnelBtn(false);
|
||||
this._updateTunnelIndicator(false);
|
||||
this.closeTunnelPanel();
|
||||
this.closeTunnelQR();
|
||||
}
|
||||
|
||||
@@ -6471,11 +6481,15 @@ class CodemanApp {
|
||||
const res = await fetch('/api/tunnel/status');
|
||||
const status = await res.json();
|
||||
const active = status.running && status.url;
|
||||
this._updateTunnelUrlDisplay(active ? status.url : null);
|
||||
this._updateWelcomeTunnelBtn(!!active, active ? status.url : null);
|
||||
this._tunnelUrl = active ? status.url : null;
|
||||
this._updateTunnelUrlDisplay(this._tunnelUrl);
|
||||
this._updateWelcomeTunnelBtn(!!active, this._tunnelUrl);
|
||||
this._updateTunnelIndicator(!!active);
|
||||
} catch {
|
||||
this._tunnelUrl = null;
|
||||
this._updateTunnelUrlDisplay(null);
|
||||
this._updateWelcomeTunnelBtn(false);
|
||||
this._updateTunnelIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6803,6 +6817,214 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Tunnel Header Indicator & Panel (desktop only)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_updateTunnelIndicator(active) {
|
||||
if (MobileDetection.getDeviceType() === 'mobile') return;
|
||||
const indicator = document.getElementById('tunnelIndicator');
|
||||
if (!indicator) return;
|
||||
indicator.style.display = active ? 'flex' : 'none';
|
||||
indicator.classList.remove('connecting');
|
||||
}
|
||||
|
||||
toggleTunnelPanel() {
|
||||
const existing = document.getElementById('tunnelPanel');
|
||||
if (existing) {
|
||||
this.closeTunnelPanel();
|
||||
return;
|
||||
}
|
||||
this._openTunnelPanel();
|
||||
}
|
||||
|
||||
async _openTunnelPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'tunnel-panel';
|
||||
panel.id = 'tunnelPanel';
|
||||
panel.innerHTML = `
|
||||
<div class="tunnel-panel-header">
|
||||
<h3>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
Cloudflare Tunnel
|
||||
<span class="tunnel-panel-status" id="tunnelPanelStatus">Loading...</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="tunnel-panel-body" id="tunnelPanelBody">
|
||||
<div style="font-size:12px;color:var(--text-muted);padding:8px 0">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
// Close on outside click
|
||||
this._tunnelPanelClickHandler = (e) => {
|
||||
if (!panel.contains(e.target) && e.target.id !== 'tunnelIndicator' && !e.target.closest('.tunnel-indicator')) {
|
||||
this.closeTunnelPanel();
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', this._tunnelPanelClickHandler), 0);
|
||||
|
||||
// Close on Escape
|
||||
this._tunnelPanelEscHandler = (e) => { if (e.key === 'Escape') this.closeTunnelPanel(); };
|
||||
document.addEventListener('keydown', this._tunnelPanelEscHandler);
|
||||
|
||||
// Fetch tunnel info
|
||||
try {
|
||||
const res = await fetch('/api/tunnel/info');
|
||||
const info = await res.json();
|
||||
this._renderTunnelPanel(info);
|
||||
} catch {
|
||||
const body = document.getElementById('tunnelPanelBody');
|
||||
if (body) body.innerHTML = '<div style="font-size:12px;color:var(--red);padding:8px 0">Failed to load tunnel info</div>';
|
||||
}
|
||||
}
|
||||
|
||||
_renderTunnelPanel(info) {
|
||||
const statusEl = document.getElementById('tunnelPanelStatus');
|
||||
const body = document.getElementById('tunnelPanelBody');
|
||||
if (!statusEl || !body) return;
|
||||
|
||||
statusEl.textContent = info.running ? 'Connected' : 'Offline';
|
||||
statusEl.className = 'tunnel-panel-status' + (info.running ? '' : ' offline');
|
||||
|
||||
let html = '';
|
||||
|
||||
// URL section
|
||||
if (info.url) {
|
||||
html += `
|
||||
<div class="tunnel-panel-section">
|
||||
<div class="tunnel-panel-label">URL</div>
|
||||
<div class="tunnel-panel-url" id="tunnelPanelUrl" title="Click to copy">${escapeHtml(info.url)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Clients section
|
||||
html += `
|
||||
<div class="tunnel-panel-section">
|
||||
<div class="tunnel-panel-label">Connections</div>
|
||||
<div class="tunnel-panel-stat">
|
||||
<span>Remote Clients</span>
|
||||
<span class="tunnel-panel-stat-value">${info.sseClients}</span>
|
||||
</div>`;
|
||||
|
||||
if (info.authEnabled) {
|
||||
html += `
|
||||
<div class="tunnel-panel-stat">
|
||||
<span>Auth Sessions</span>
|
||||
<span class="tunnel-panel-stat-value">${info.authSessions.length}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Auth sessions detail
|
||||
if (info.authEnabled && info.authSessions.length > 0) {
|
||||
html += '<div class="tunnel-panel-section"><div class="tunnel-panel-label">Authenticated Devices</div>';
|
||||
for (const s of info.authSessions) {
|
||||
const ua = s.ua || 'Unknown';
|
||||
const browser = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser';
|
||||
const ago = this._formatTimeAgo(s.createdAt);
|
||||
html += `
|
||||
<div class="tunnel-panel-session">
|
||||
<span class="tunnel-panel-session-dot"></span>
|
||||
<span class="tunnel-panel-session-info" title="${escapeHtml(ua)}">${escapeHtml(browser)} · ${escapeHtml(s.ip)} · ${ago}</span>
|
||||
<span class="tunnel-panel-session-method">${s.method}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Actions
|
||||
html += '<div class="tunnel-panel-actions">';
|
||||
if (info.running) {
|
||||
html += `
|
||||
<button class="tunnel-panel-btn btn-qr" onclick="app.showTunnelQR();app.closeTunnelPanel()">QR Code</button>
|
||||
<button class="tunnel-panel-btn btn-stop" onclick="app._tunnelPanelToggle(false)">Stop Tunnel</button>`;
|
||||
} else {
|
||||
html += `<button class="tunnel-panel-btn btn-start" onclick="app._tunnelPanelToggle(true)">Start Tunnel</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Revoke all sessions button
|
||||
if (info.authEnabled && info.authSessions.length > 0) {
|
||||
html += `
|
||||
<div style="padding-top:8px">
|
||||
<button class="tunnel-panel-btn btn-revoke" style="width:100%" onclick="app._tunnelPanelRevokeAll()">Revoke All Sessions</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
// Bind URL copy handler
|
||||
const urlEl = document.getElementById('tunnelPanelUrl');
|
||||
if (urlEl) {
|
||||
urlEl.onclick = () => {
|
||||
navigator.clipboard.writeText(info.url).then(() => this.showToast('Tunnel URL copied', 'success'));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_formatTimeAgo(timestamp) {
|
||||
const diff = Date.now() - timestamp;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
async _tunnelPanelToggle(enable) {
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tunnelEnabled: enable }),
|
||||
});
|
||||
if (enable) {
|
||||
this._updateTunnelIndicator(false);
|
||||
const indicator = document.getElementById('tunnelIndicator');
|
||||
if (indicator) {
|
||||
indicator.style.display = 'flex';
|
||||
indicator.classList.add('connecting');
|
||||
}
|
||||
this.showToast('Tunnel starting...', 'info');
|
||||
this._showTunnelConnecting();
|
||||
this._pollTunnelStatus();
|
||||
} else {
|
||||
this.showToast('Tunnel stopped', 'info');
|
||||
}
|
||||
this.closeTunnelPanel();
|
||||
} catch {
|
||||
this.showToast('Failed to toggle tunnel', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async _tunnelPanelRevokeAll() {
|
||||
try {
|
||||
await fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
this.showToast('All sessions revoked', 'success');
|
||||
// Refresh panel
|
||||
const res = await fetch('/api/tunnel/info');
|
||||
const info = await res.json();
|
||||
this._renderTunnelPanel(info);
|
||||
} catch {
|
||||
this.showToast('Failed to revoke sessions', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
closeTunnelPanel() {
|
||||
const panel = document.getElementById('tunnelPanel');
|
||||
if (panel) panel.remove();
|
||||
if (this._tunnelPanelClickHandler) {
|
||||
document.removeEventListener('click', this._tunnelPanelClickHandler);
|
||||
this._tunnelPanelClickHandler = null;
|
||||
}
|
||||
if (this._tunnelPanelEscHandler) {
|
||||
document.removeEventListener('keydown', this._tunnelPanelEscHandler);
|
||||
this._tunnelPanelEscHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
toggleDeepgramKeyVisibility() {
|
||||
const input = document.getElementById('voiceDeepgramKey');
|
||||
const btn = document.getElementById('voiceKeyToggleBtn');
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
</button>
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
|
||||
@@ -444,6 +444,249 @@ body {
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Tunnel Indicator */
|
||||
.tunnel-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tunnel-indicator:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4);
|
||||
animation: tunnel-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tunnel-indicator.connecting .tunnel-dot {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4);
|
||||
animation: tunnel-glow-amber 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px #22c55e, 0 0 12px rgba(34, 197, 94, 0.4); }
|
||||
50% { box-shadow: 0 0 8px #22c55e, 0 0 18px rgba(34, 197, 94, 0.6); }
|
||||
}
|
||||
|
||||
@keyframes tunnel-glow-amber {
|
||||
0%, 100% { box-shadow: 0 0 6px #f59e0b, 0 0 12px rgba(245, 158, 11, 0.4); opacity: 1; }
|
||||
50% { box-shadow: 0 0 4px #f59e0b; opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Tunnel Panel Dropdown */
|
||||
.tunnel-panel {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
right: 12px;
|
||||
width: 320px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tunnel-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tunnel-panel-status {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-status.offline {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tunnel-panel-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tunnel-panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tunnel-panel-url {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-hover);
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-url:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-stat-value {
|
||||
color: var(--text);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-session-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tunnel-panel-session-method {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tunnel-panel-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-start:hover {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-stop:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr {
|
||||
background: var(--bg-input);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-qr:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tunnel-panel-btn.btn-revoke:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tunnel-panel-empty {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -28,6 +28,7 @@ const noopEventPort: EventPort = {
|
||||
batchTerminalData: () => {},
|
||||
broadcastSessionStateDebounced: () => {},
|
||||
batchTaskUpdate: () => {},
|
||||
getSseClientCount: () => 0,
|
||||
};
|
||||
|
||||
export function registerRespawnRoutes(
|
||||
|
||||
@@ -104,6 +104,23 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
|
||||
|
||||
app.get('/api/tunnel/info', async () => {
|
||||
const status = ctx.tunnelManager.getStatus();
|
||||
const sseClients = ctx.getSseClientCount();
|
||||
const sessions: Array<{ ip: string; ua: string; createdAt: number; method: string }> = [];
|
||||
if (ctx.authSessions) {
|
||||
for (const [, record] of ctx.authSessions) {
|
||||
sessions.push({ ip: record.ip, ua: record.ua, createdAt: record.createdAt, method: record.method });
|
||||
}
|
||||
}
|
||||
return {
|
||||
...status,
|
||||
sseClients,
|
||||
authEnabled: !!process.env.CODEMAN_PASSWORD,
|
||||
authSessions: sessions,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/tunnel/qr', async (_req, reply) => {
|
||||
const url = ctx.tunnelManager.getUrl();
|
||||
if (!url) {
|
||||
|
||||
@@ -217,6 +217,8 @@ export class WebServer extends EventEmitter {
|
||||
* or `null` meaning "receive all events" (backwards-compatible default).
|
||||
*/
|
||||
private sseClients: Map<FastifyReply, Set<string> | null> = new Map();
|
||||
/** SSE clients connecting from non-localhost (i.e. through tunnel) */
|
||||
private remoteSseClients: Set<FastifyReply> = new Set();
|
||||
/** Clients with backpressure — skip writes until 'drain' fires */
|
||||
private backpressuredClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
@@ -505,6 +507,7 @@ export class WebServer extends EventEmitter {
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
getSseClientCount: () => this.remoteSseClients.size,
|
||||
// RespawnPort
|
||||
respawnControllers: this.respawnControllers,
|
||||
respawnTimers: this.respawnTimers,
|
||||
@@ -620,6 +623,12 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
this.sseClients.set(reply, sessionFilter);
|
||||
|
||||
// Track tunnel clients — cloudflared proxies locally so req.ip is always
|
||||
// 127.0.0.1; detect tunnel traffic via Cf-Connecting-Ip header instead.
|
||||
if (req.headers['cf-connecting-ip']) {
|
||||
this.remoteSseClients.add(reply);
|
||||
}
|
||||
|
||||
// Send initial state
|
||||
// Use light state for SSE init to avoid sending 2MB+ terminal buffers
|
||||
// Buffers are fetched on-demand when switching tabs
|
||||
@@ -636,6 +645,7 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
req.raw.on('close', () => {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
});
|
||||
});
|
||||
@@ -1947,6 +1957,7 @@ export class WebServer extends EventEmitter {
|
||||
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1976,6 +1987,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
} catch {
|
||||
this.sseClients.delete(reply);
|
||||
this.remoteSseClients.delete(reply);
|
||||
this.backpressuredClients.delete(reply);
|
||||
}
|
||||
}
|
||||
@@ -2316,6 +2328,7 @@ export class WebServer extends EventEmitter {
|
||||
// Remove dead clients
|
||||
for (const client of deadClients) {
|
||||
this.sseClients.delete(client);
|
||||
this.remoteSseClients.delete(client);
|
||||
this.backpressuredClients.delete(client);
|
||||
}
|
||||
|
||||
@@ -2704,6 +2717,7 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
this.sseClients.clear();
|
||||
this.remoteSseClients.clear();
|
||||
this.backpressuredClients.clear();
|
||||
|
||||
// Clear per-session batch timers
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) {
|
||||
batchTerminalData: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
getSseClientCount: vi.fn(() => 0),
|
||||
|
||||
// -- RespawnPort --
|
||||
respawnControllers: new Map(),
|
||||
|
||||
@@ -16,69 +16,54 @@ import { IOSKeyboard } from '../components/IOSKeyboard';
|
||||
// ─── Scene timing (frames @ 30fps) ───
|
||||
const TITLE_DUR = 60;
|
||||
const PHONES_DUR = 30;
|
||||
const TYPING_DUR = 468;
|
||||
const HOLD_DUR = 60;
|
||||
const TYPING_DUR = 610;
|
||||
const HOLD_DUR = 50;
|
||||
const OUTRO_DUR = 45;
|
||||
|
||||
const TITLE_START = 0;
|
||||
const PHONES_START = TITLE_DUR; // 60
|
||||
const TYPING_START = PHONES_START + PHONES_DUR; // 90
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 558
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 618
|
||||
const HOLD_START = TYPING_START + TYPING_DUR; // 700
|
||||
const OUTRO_START = HOLD_START + HOLD_DUR; // 750
|
||||
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 663
|
||||
export const ZEROLAG_TOTAL_FRAMES = OUTRO_START + OUTRO_DUR; // 795
|
||||
|
||||
// ─── iPhone 17 Pro safe area ───
|
||||
const SAFE_AREA_TOP = 59; // Below Dynamic Island
|
||||
const PHONE_SCALE = 1.12; // Scale up to fill more of the frame
|
||||
|
||||
// The Codeman screenshot starts content at y=0 (the session tab).
|
||||
// On a real device it would sit below the safe area, so we offset it.
|
||||
const SCREENSHOT_Y_OFFSET = SAFE_AREA_TOP;
|
||||
// Claude Code header: tab bar + session info + prompt context from screenshot
|
||||
const HEADER_H = 120;
|
||||
|
||||
// Terminal typing overlay position (relative to screenshot top)
|
||||
// Session tab is ~44px, then terminal starts. Adding safe area offset:
|
||||
const TERMINAL_TOP = SAFE_AREA_TOP + 52;
|
||||
// Terminal typing overlay: aligned with the ❯ prompt position in the Claude Code screenshot
|
||||
const TERMINAL_TOP = 185;
|
||||
const TERMINAL_LEFT = 14;
|
||||
const TERMINAL_FONT = 22; // Large for video readability
|
||||
const TERMINAL_FONT = 21; // Slightly smaller to fit toolbar below
|
||||
|
||||
// ─── Typing schedule (with typo + backspace correction) ───
|
||||
const CORRECT_TEXT = 'fix the auth bug in the login flow';
|
||||
const FRAME_GAP = 12; // ~400ms between keystrokes
|
||||
const TYPO_INDEX = 28; // After "logi", type "m" instead of "n"
|
||||
// Codeman toolbar from screenshot (bottom section showing /init, /clear, Run, etc.)
|
||||
const TOOLBAR_H = 95;
|
||||
|
||||
// Remote connection lag: 600ms-1.2s+ per char (18-36+ frames)
|
||||
// ─── Typing schedule ───
|
||||
const CORRECT_TEXT =
|
||||
'zerolag technology brings in a visual dom overlay to make typing instant, even if your codeman server is on the other side of the world';
|
||||
const FRAME_GAP = 4; // ~133ms per keystroke (~75 WPM)
|
||||
|
||||
// Remote connection lag: 600ms–2.7s per char (18–80 frames @ 30fps).
|
||||
// Periodic spikes simulate packet loss / retransmission bursts.
|
||||
// TCP head-of-line blocking causes a single spike to freeze all subsequent chars.
|
||||
const LAGGY_DELAYS = [
|
||||
24, 30, 36, 32, 26, 22, 34, 28, 38, 20, 30, 24, 32, 26, 36, 22,
|
||||
30, 24, 32, 28, 34, 26, 30, 22, 28, 36, 24, 30, 32, 26, 34, 28, 24, 30,
|
||||
26, 32, 28, 34,
|
||||
24, 30, 26, 32, 72, 28, 22, 34, 26, 30, 20, 28, 36, 24, 30, 22, 26, 34, 28, 20, 68, 30, 24, 32,
|
||||
26, 22, 28, 34, 30, 26, 32, 24, 80, 22, 30, 26, 28, 34, 24, 30,
|
||||
];
|
||||
|
||||
type KeyAction = { frame: number; action: 'type' | 'backspace'; char: string; lagDelay: number };
|
||||
type KeyAction = { frame: number; char: string; lagDelay: number };
|
||||
|
||||
const buildSchedule = (): KeyAction[] => {
|
||||
const actions: KeyAction[] = [];
|
||||
let idx = 0;
|
||||
const lag = (i: number) => LAGGY_DELAYS[i % LAGGY_DELAYS.length];
|
||||
|
||||
// Type correctly up to typo point: "fix the auth bug in the logi"
|
||||
for (let i = 0; i < TYPO_INDEX; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Typo: type "m" instead of "n"
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: 'm', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Backspace to fix it
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'backspace', char: '⌫', lagDelay: lag(idx) });
|
||||
idx++;
|
||||
|
||||
// Type correct remaining: "n flow"
|
||||
for (let i = TYPO_INDEX; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: idx * FRAME_GAP, action: 'type', char: CORRECT_TEXT[i], lagDelay: lag(idx) });
|
||||
idx++;
|
||||
for (let i = 0; i < CORRECT_TEXT.length; i++) {
|
||||
actions.push({ frame: i * FRAME_GAP, char: CORRECT_TEXT[i], lagDelay: lag(i) });
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -86,14 +71,16 @@ const buildSchedule = (): KeyAction[] => {
|
||||
|
||||
const TYPING_SCHEDULE = buildSchedule();
|
||||
|
||||
/** Replay actions in order up to current frame, computing the visible text buffer */
|
||||
/**
|
||||
* Replay actions in order up to current frame, computing the visible text buffer.
|
||||
* TCP-ordered: stops at first unresolved echo (head-of-line blocking).
|
||||
*/
|
||||
const computeVisibleText = (frame: number, withLag: boolean): string => {
|
||||
let buffer = '';
|
||||
for (const a of TYPING_SCHEDULE) {
|
||||
const threshold = withLag ? a.frame + a.lagDelay : a.frame;
|
||||
if (frame < threshold) break; // TCP-ordered: stop at first unresolved
|
||||
if (a.action === 'backspace') buffer = buffer.slice(0, -1);
|
||||
else buffer += a.char;
|
||||
if (frame < threshold) break;
|
||||
buffer += a.char;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
@@ -128,8 +115,20 @@ const IOSStatusBar: React.FC = () => (
|
||||
</svg>
|
||||
{/* WiFi */}
|
||||
<svg width="16" height="12" viewBox="0 0 16 12">
|
||||
<path d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4" stroke="#fff" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||
<path
|
||||
d="M4.5 8.5C5.5 7.2 6.7 6.5 8 6.5s2.5.7 3.5 2"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 5.5C3.5 3 5.7 1.5 8 1.5s4.5 1.5 6.5 4"
|
||||
stroke="#fff"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="8" cy="11" r="1.5" fill="#fff" />
|
||||
</svg>
|
||||
{/* Battery */}
|
||||
@@ -145,9 +144,8 @@ const IOSStatusBar: React.FC = () => (
|
||||
// ─── Typing overlay ───
|
||||
const TypingOverlay: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible }) => {
|
||||
}> = ({ typed, cursorVisible }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const cursorOpacity = cursorVisible ? (Math.floor(frame / 18) % 2 === 0 ? 0.85 : 0.5) : 0;
|
||||
const lineH = Math.round(TERMINAL_FONT * 1.4);
|
||||
@@ -167,13 +165,7 @@ const TypingOverlay: React.FC<{
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#339af0', fontWeight: 700 }}>{'❯ '}</span>
|
||||
{overlayChars
|
||||
? overlayChars.map((oc, i) => (
|
||||
<span key={i} style={{ color: oc.confirmed ? '#e0e0e0' : '#666' }}>
|
||||
{oc.char}
|
||||
</span>
|
||||
))
|
||||
: <span style={{ color: '#e0e0e0' }}>{typed}</span>}
|
||||
<span style={{ color: '#e0e0e0' }}>{typed}</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
@@ -188,37 +180,116 @@ const TypingOverlay: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Single phone: iPhone 17 Pro + real screenshot + typing + keyboard ───
|
||||
// ─── Single phone: iPhone 17 Pro + Claude Code header + typing + keyboard ───
|
||||
const MobileCodeman: React.FC<{
|
||||
typed: string;
|
||||
overlayChars?: { char: string; confirmed: boolean }[];
|
||||
cursorVisible: boolean;
|
||||
activeKey?: string;
|
||||
pressAge?: number;
|
||||
showKeyboard?: boolean;
|
||||
noAnimation?: boolean;
|
||||
}> = ({ typed, overlayChars, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation }) => (
|
||||
showPromo?: boolean;
|
||||
}> = ({ typed, cursorVisible, activeKey, pressAge, showKeyboard = true, noAnimation, showPromo }) => (
|
||||
<IPhone17ProFrame noAnimation={noAnimation}>
|
||||
<div style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#000' }}>
|
||||
{/* Real Codeman mobile screenshot, pushed down by safe area */}
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
height: SCREEN_H - SCREENSHOT_Y_OFFSET,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top',
|
||||
position: 'absolute',
|
||||
top: SCREENSHOT_Y_OFFSET,
|
||||
left: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ width: SCREEN_W, height: SCREEN_H, position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}
|
||||
>
|
||||
{/* iOS status bar in the safe area */}
|
||||
<IOSStatusBar />
|
||||
|
||||
{/* Claude Code header from screenshot (tabs + session info) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: HEADER_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'top left',
|
||||
}}
|
||||
/>
|
||||
{/* Fade to terminal background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 30,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dark mask: hides screenshot text below Claude Code info (e.g. "Try edit...") */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: SAFE_AREA_TOP + 95,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 8,
|
||||
}}
|
||||
>
|
||||
{/* Smooth gradient blend from screenshot into dark terminal */}
|
||||
<div
|
||||
style={{
|
||||
height: 18,
|
||||
background: 'linear-gradient(transparent, #0d0d0d)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, background: '#0d0d0d' }} />
|
||||
</div>
|
||||
|
||||
{/* Typing animation */}
|
||||
<TypingOverlay typed={typed} overlayChars={overlayChars} cursorVisible={cursorVisible} />
|
||||
<TypingOverlay typed={typed} cursorVisible={cursorVisible} />
|
||||
|
||||
{/* Codeman toolbar from screenshot (/init, /clear, /compact, Run, Run Shell, voice) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 232,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: TOOLBAR_H,
|
||||
overflow: 'hidden',
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
{/* Gradient blend at top */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 18,
|
||||
background: 'linear-gradient(#0d0d0d, transparent)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile('mobile-claude.png')}
|
||||
style={{
|
||||
width: SCREEN_W,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Promo cards between text and toolbar (left phone only) */}
|
||||
{showPromo && <PromoBanners />}
|
||||
|
||||
{/* iOS keyboard at bottom */}
|
||||
{showKeyboard && (
|
||||
@@ -230,22 +301,22 @@ const MobileCodeman: React.FC<{
|
||||
</IPhone17ProFrame>
|
||||
);
|
||||
|
||||
// ─── Label beneath phone ───
|
||||
// ─── Label above phone (prominent header) ───
|
||||
const PhoneLabel: React.FC<{
|
||||
title: string;
|
||||
detail: string;
|
||||
dotColor: string;
|
||||
detailColor: string;
|
||||
}> = ({ title, detail, dotColor, detailColor }) => (
|
||||
<div style={{ textAlign: 'center', marginTop: 20 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fontFamily: fonts.ui,
|
||||
color: '#fff',
|
||||
}}
|
||||
@@ -267,6 +338,62 @@ const PhoneLabel: React.FC<{
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Promo banners (inside left phone, between text and keyboard) ───
|
||||
const PromoBanners: React.FC = () => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 332,
|
||||
left: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" fill="#ccc" style={{ flexShrink: 0 }}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>Codeman</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>github.com/Ark0N/Codeman</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 16 16" style={{ flexShrink: 0 }}>
|
||||
<rect width="16" height="16" rx="2" fill="#cb3837" />
|
||||
<path d="M3 3h10v10H8V5H5v8H3z" fill="#fff" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: fonts.ui, color: '#e0e0e0' }}>xterm-zerolag-input</div>
|
||||
<div style={{ fontSize: 12, fontFamily: fonts.mono, color: '#888' }}>npmjs.com/package/xterm-zerolag-input</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Title scene ───
|
||||
const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
@@ -357,12 +484,22 @@ const TypingDemo: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed={zerolagTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation showPromo />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed={laggyTyped} cursorVisible activeKey={activeKey} pressAge={pressAge} noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
@@ -389,12 +526,22 @@ const PanelsEntrance: React.FC = () => {
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 50, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="With Zerolag"
|
||||
detail="0ms local echo"
|
||||
dotColor={colors.accent.green}
|
||||
detailColor={colors.accent.green}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="With Zerolag" detail="0ms delay" dotColor={colors.accent.green} detailColor={colors.accent.green} />
|
||||
</div>
|
||||
<div>
|
||||
<PhoneLabel
|
||||
title="Without Zerolag"
|
||||
detail="600ms–2.7s server echo"
|
||||
dotColor={colors.accent.red}
|
||||
detailColor={colors.accent.red}
|
||||
/>
|
||||
<MobileCodeman typed="" cursorVisible noAnimation />
|
||||
<PhoneLabel title="Without Zerolag" detail="600ms–1.2s delay" dotColor={colors.accent.red} detailColor={colors.accent.red} />
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 390 KiB |
Reference in New Issue
Block a user