mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
refactor: split app.js into focused frontend modules (phase 5)
Extract 8 modules from the 15K-line app.js monolith:
- constants.js: shared constants, timing, escapeHtml(), extractSyncSegments()
- mobile-handlers.js: MobileDetection, KeyboardHandler, SwipeHandler
- voice-input.js: DeepgramProvider, VoiceInput
- notification-manager.js: NotificationManager class
- keyboard-accessory.js: KeyboardAccessoryBar, FocusTrap
- api-client.js: _api(), _apiJson(), _apiPost(), _apiDelete() helpers
- subagent-windows.js: 13 subagent window methods (open, close, drag, lines)
- vendor/xterm-zerolag-input.js: IIFE build replacing inlined copy
app.js reduced from ~15,200 to ~11,500 lines. All modules use global
scope with <script defer> ordering. Prototype extensions use
Object.assign(CodemanApp.prototype, {...}) pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -226,8 +226,15 @@ Re-exported via `src/utils/index.ts`. Key exports:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/web/public/index.html` | HTML entry point with inline critical CSS and async vendor loading |
|
||||
| `src/web/public/app.js` | Core UI: xterm.js, tab management, subagent windows, mobile support (~15K lines) |
|
||||
| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI extracted from app.js (~1K lines) |
|
||||
| `src/web/public/constants.js` | Shared constants, timing values, Z-index layers, Web Push utilities |
|
||||
| `src/web/public/api-client.js` | API fetch wrapper (`_api`, `_apiJson`, `_apiPost`) |
|
||||
| `src/web/public/mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` objects |
|
||||
| `src/web/public/voice-input.js` | `DeepgramProvider`, `VoiceInput` objects for speech-to-text |
|
||||
| `src/web/public/notification-manager.js` | `NotificationManager` class (5-layer notification system) |
|
||||
| `src/web/public/keyboard-accessory.js` | `KeyboardAccessoryBar` and `FocusTrap` classes |
|
||||
| `src/web/public/subagent-windows.js` | Subagent window management (open, close, drag, connection lines) |
|
||||
| `src/web/public/app.js` | Core UI: xterm.js, tab management, settings |
|
||||
| `src/web/public/ralph-wizard.js` | Ralph Loop wizard UI |
|
||||
| `src/web/public/styles.css` | Main styling (dark theme, layout, components) |
|
||||
| `src/web/public/mobile.css` | Responsive overrides for screens <1024px (loaded conditionally via `media` attribute) |
|
||||
| `src/web/public/upload.html` | Screenshot upload page served at `/upload.html` |
|
||||
@@ -235,20 +242,23 @@ Re-exported via `src/utils/index.ts`. Key exports:
|
||||
| `src/web/public/manifest.json` | Minimal PWA manifest (required for push on Android) |
|
||||
| `src/web/public/vendor/` | Self-hosted xterm.js + addons (eliminates CDN latency) |
|
||||
|
||||
### Frontend Architecture (`app.js`)
|
||||
**Script loading order** (index.html): `constants.js` → `mobile-handlers.js` → `voice-input.js` → `notification-manager.js` → `keyboard-accessory.js` → `app.js` → `ralph-wizard.js` → `api-client.js` → `subagent-windows.js`. All modules share global scope — order matters for dependencies.
|
||||
|
||||
The frontend is a single ~15K-line vanilla JS file with these key systems:
|
||||
### Frontend Architecture
|
||||
|
||||
| System | Key Classes/Functions | Purpose |
|
||||
|--------|----------------------|---------|
|
||||
| **Terminal rendering** | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync |
|
||||
| **Local echo overlay** | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback |
|
||||
| **Mobile support** | `MobileDetection`, `KeyboardHandler`, `SwipeHandler`, `KeyboardAccessoryBar` | Touch input, viewport adaptation, swipe navigation |
|
||||
| **Subagent windows** | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines |
|
||||
| **Notifications** | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep |
|
||||
| **SSE connection** | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) |
|
||||
| **Settings** | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence |
|
||||
| **Focus management** | `FocusTrap` class | Modal keyboard navigation with focus restore |
|
||||
The frontend is split across multiple vanilla JS modules (extracted from the original monolithic `app.js`). Key systems:
|
||||
|
||||
| System | Module | Key Classes/Functions | Purpose |
|
||||
|--------|--------|----------------------|---------|
|
||||
| **Terminal rendering** | `app.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()` | 60fps batched writes with DEC 2026 sync |
|
||||
| **Local echo overlay** | `app.js` | `LocalEchoOverlay` class | DOM overlay for instant mobile keystroke feedback |
|
||||
| **Mobile support** | `mobile-handlers.js` | `MobileDetection`, `KeyboardHandler`, `SwipeHandler` | Touch input, viewport adaptation, swipe navigation |
|
||||
| **Keyboard accessory** | `keyboard-accessory.js` | `KeyboardAccessoryBar`, `FocusTrap` | Mobile keyboard toolbar, modal focus management |
|
||||
| **Subagent windows** | `subagent-windows.js` | `openSubagentWindow()`, `closeSubagentWindow()`, `updateConnectionLines()` | Floating terminal windows with parent connection lines |
|
||||
| **Notifications** | `notification-manager.js` | `NotificationManager` class | 5-layer: in-app drawer, tab flash, browser API, web push, audio beep |
|
||||
| **Voice input** | `voice-input.js` | `DeepgramProvider`, `VoiceInput` | Speech-to-text via Deepgram WebSocket |
|
||||
| **SSE connection** | `app.js` | `connectSSE()`, `addListener()` | EventSource with exponential backoff (1-30s), offline queue (64KB) |
|
||||
| **Settings** | `app.js` | `openAppSettings()`, `apply*Visibility()` | Server-backed + localStorage persistence |
|
||||
|
||||
**Z-index layers**: subagent windows (1000), plan agents (1100), log viewers (2000), image popups (3000), local echo overlay (7).
|
||||
|
||||
@@ -286,21 +296,22 @@ The frontend is a single ~15K-line vanilla JS file with these key systems:
|
||||
|
||||
### API Route Categories
|
||||
|
||||
~280 route handlers split across `src/web/routes/` domain modules. Key groups:
|
||||
~110 route handlers split across `src/web/routes/` domain modules. Key groups:
|
||||
|
||||
| Group | Prefix | Count | Key endpoints |
|
||||
|-------|--------|-------|---------------|
|
||||
| Sessions | `/api/sessions` | ~20 | CRUD, input, resize, interactive, shell |
|
||||
| Respawn | `/api/sessions/:id/respawn` | 5 | start, stop, enable, config |
|
||||
| Ralph | `/api/sessions/:id/ralph-*` | 6 | state, status, config, circuit-breaker |
|
||||
| Plan | `/api/sessions/:id/plan/*` | 5 | task CRUD, checkpoint, history, rollback |
|
||||
| Sessions | `/api/sessions` | ~24 | CRUD, input, resize, interactive, shell |
|
||||
| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings` | ~32 | App state, config |
|
||||
| Ralph | `/api/sessions/:id/ralph-*` | 9 | state, status, config, circuit-breaker |
|
||||
| Plan | `/api/sessions/:id/plan/*` | 8 | task CRUD, checkpoint, history, rollback |
|
||||
| Respawn | `/api/sessions/:id/respawn` | 7 | start, stop, enable, config |
|
||||
| Subagents | `/api/subagents` | 7 | list, transcript, kill, cleanup |
|
||||
| Cases | `/api/cases` | 5 | CRUD, link, fix-plan |
|
||||
| Cases | `/api/cases` | 7 | CRUD, link, fix-plan |
|
||||
| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream |
|
||||
| Mux | `/api/mux-sessions` | 5 | tmux management, stats |
|
||||
| Scheduled | `/api/scheduled` | 4 | CRUD for scheduled runs |
|
||||
| Push | `/api/push` | 4 | VAPID key, subscribe, update prefs, unsubscribe |
|
||||
| System | `/api/status`, `/api/stats`, `/api/config`, `/api/settings` | 8 | App state, config |
|
||||
| Files | `/api/sessions/:id/file*`, `tail-file` | 5 | Browser, preview, raw, tail stream |
|
||||
| Mux | `/api/mux-sessions` | 4 | tmux management, stats |
|
||||
| Teams | `/api/teams` | 2 | list teams, get team tasks |
|
||||
|
||||
## Adding Features
|
||||
|
||||
@@ -448,7 +459,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
||||
| **SSE events** | Search `broadcast(` in `server.ts` and route modules |
|
||||
| **Session statuses** | `SessionStatus` in `src/types/session.ts` |
|
||||
| **Error codes** | `createErrorResponse()` in `src/types/api.ts` |
|
||||
| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase-4-domain-splitting-plan.md` |
|
||||
| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase5-frontend-modularization-plan.md` |
|
||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||
| **Mobile test suite** | `mobile-test/README.md` |
|
||||
| **OpenCode integration** | `docs/opencode-integration.md` |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig([
|
||||
// Standard builds (CJS + ESM + DTS)
|
||||
{
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
},
|
||||
// IIFE build for browser <script> tag usage
|
||||
{
|
||||
entry: ['src/index.ts'],
|
||||
format: ['iife'],
|
||||
globalName: 'XtermZerolagInput',
|
||||
outDir: 'dist',
|
||||
// Append global aliases so app.js can access classes directly
|
||||
footer: {
|
||||
js: [
|
||||
'// Global aliases for browser usage',
|
||||
'if(typeof window!=="undefined"){',
|
||||
' window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;',
|
||||
' window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{',
|
||||
' constructor(terminal){',
|
||||
' super({prompt:{type:"character",char:"\\u276f",offset:2}});',
|
||||
' this.activate(terminal);',
|
||||
' }',
|
||||
' };',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Codeman — Centralized API fetch helpers for CodemanApp
|
||||
// Loaded after app.js (needs CodemanApp class defined)
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Send a JSON API request. Handles Content-Type, JSON serialization, and error swallowing.
|
||||
* @param {string} path - API path (e.g., '/api/sessions/123/input')
|
||||
* @param {object} [opts] - { method, body, signal }
|
||||
* @returns {Promise<Response|null>} Response or null on error
|
||||
*/
|
||||
async _api(path, opts = {}) {
|
||||
const { method = 'GET', body, signal } = opts;
|
||||
const fetchOpts = { method, signal };
|
||||
if (body !== undefined) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' };
|
||||
fetchOpts.body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(path, fetchOpts);
|
||||
return res;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a JSON API request and parse the response as JSON.
|
||||
* @param {string} path - API path
|
||||
* @param {object} [opts] - { method, body, signal }
|
||||
* @returns {Promise<any|null>} Parsed JSON or null on error
|
||||
*/
|
||||
async _apiJson(path, opts = {}) {
|
||||
const res = await this._api(path, opts);
|
||||
if (!res || !res.ok) return null;
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST JSON to an API endpoint (most common pattern).
|
||||
* @param {string} path - API path
|
||||
* @param {object} body - JSON body
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async _apiPost(path, body) {
|
||||
return this._api(path, { method: 'POST', body });
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE an API resource.
|
||||
* @param {string} path - API path
|
||||
* @returns {Promise<Response|null>}
|
||||
*/
|
||||
async _apiDelete(path) {
|
||||
return this._api(path, { method: 'DELETE' });
|
||||
},
|
||||
});
|
||||
+79
-3770
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ============================================================================
|
||||
// Web Push Utilities
|
||||
// ============================================================================
|
||||
|
||||
/** Convert a base64-encoded VAPID key to Uint8Array for pushManager.subscribe() */
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const rawData = atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
// Default terminal scrollback (can be changed via settings)
|
||||
const DEFAULT_SCROLLBACK = 5000;
|
||||
|
||||
// Timing constants
|
||||
const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection
|
||||
const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window
|
||||
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
|
||||
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
|
||||
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
|
||||
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
|
||||
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
|
||||
const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
|
||||
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
|
||||
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
|
||||
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
|
||||
|
||||
// Z-index base values for layered floating windows
|
||||
const ZINDEX_SUBAGENT_BASE = 1000;
|
||||
const ZINDEX_PLAN_SUBAGENT_BASE = 1100;
|
||||
const ZINDEX_LOG_VIEWER_BASE = 2000;
|
||||
const ZINDEX_IMAGE_POPUP_BASE = 3000;
|
||||
|
||||
// Subagent/floating window layout
|
||||
const WINDOW_INITIAL_TOP_PX = 120;
|
||||
const WINDOW_CASCADE_OFFSET_PX = 30;
|
||||
const WINDOW_MIN_WIDTH_PX = 200;
|
||||
const WINDOW_MIN_HEIGHT_PX = 200;
|
||||
const WINDOW_DEFAULT_WIDTH_PX = 300;
|
||||
|
||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
|
||||
// so the main thread stays free for terminal rendering at 60fps.
|
||||
const _hasScheduler = typeof globalThis.scheduler?.postTask === 'function';
|
||||
function scheduleBackground(fn) {
|
||||
if (_hasScheduler) { scheduler.postTask(fn, { priority: 'background' }); }
|
||||
else { requestAnimationFrame(fn); }
|
||||
}
|
||||
|
||||
// DEC mode 2026 - Synchronized Output
|
||||
// Wrap terminal writes with these markers to prevent partial-frame flicker.
|
||||
// Terminal buffers all output between markers and renders atomically.
|
||||
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
|
||||
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
|
||||
const DEC_SYNC_START = '\x1b[?2026h';
|
||||
const DEC_SYNC_END = '\x1b[?2026l';
|
||||
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
|
||||
const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g;
|
||||
|
||||
// Built-in respawn configuration presets
|
||||
const BUILTIN_RESPAWN_PRESETS = [
|
||||
{
|
||||
id: 'solo-work',
|
||||
name: 'Solo',
|
||||
description: 'Claude working alone — fast respawn cycles with context reset',
|
||||
config: {
|
||||
idleTimeoutMs: 3000,
|
||||
updatePrompt: 'summarize your progress so far before the context reset.',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'continue working. Pick up where you left off based on the context above.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 60,
|
||||
builtIn: true,
|
||||
createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'subagent-workflow',
|
||||
name: 'Subagents',
|
||||
description: 'Lead session with Task tool subagents — longer idle tolerance',
|
||||
config: {
|
||||
idleTimeoutMs: 45000,
|
||||
updatePrompt: 'check on your running subagents and summarize their results before the context reset. If all subagents have finished, note what was completed and what remains.',
|
||||
interStepDelayMs: 3000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'check on your running subagents and continue coordinating their work. If all subagents have finished, summarize their results and proceed with the next step.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 240,
|
||||
builtIn: true,
|
||||
createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'team-lead',
|
||||
name: 'Team',
|
||||
description: 'Leading an agent team via TeamCreate — tolerates long silences',
|
||||
config: {
|
||||
idleTimeoutMs: 90000,
|
||||
updatePrompt: 'review the task list and teammate progress. Summarize the current state before the context reset.',
|
||||
interStepDelayMs: 5000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'check on your teammates by reviewing the task list and any messages in your inbox. Assign new tasks if teammates are idle, or continue coordinating the team effort.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 480,
|
||||
builtIn: true,
|
||||
createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'ralph-todo',
|
||||
name: 'Ralph/Todo',
|
||||
description: 'Ralph Loop task list — works through todos with progress tracking',
|
||||
config: {
|
||||
idleTimeoutMs: 8000,
|
||||
updatePrompt: 'update CLAUDE.md with discoveries and progress notes, mark completed tasks in @fix_plan.md, write a brief summary so the next cycle can continue seamlessly.',
|
||||
interStepDelayMs: 3000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'read @fix_plan.md for task status, continue on the next uncompleted task. When ALL tasks are complete, output <promise>COMPLETE</promise>.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 480,
|
||||
builtIn: true,
|
||||
createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: 'overnight-autonomous',
|
||||
name: 'Overnight',
|
||||
description: 'Unattended overnight runs with full context reset between cycles',
|
||||
config: {
|
||||
idleTimeoutMs: 10000,
|
||||
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md so the next cycle can pick up where you left off.',
|
||||
interStepDelayMs: 3000,
|
||||
sendClear: true,
|
||||
sendInit: true,
|
||||
kickstartPrompt: 'continue working on the task. Pick up where you left off based on the context above.',
|
||||
autoAcceptPrompts: true,
|
||||
},
|
||||
durationMinutes: 480,
|
||||
builtIn: true,
|
||||
createdAt: 0,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get unified coordinates from mouse or touch event.
|
||||
* @param {MouseEvent|TouchEvent} e - The event
|
||||
* @returns {{ clientX: number, clientY: number }} Coordinates
|
||||
*/
|
||||
function getEventCoords(e) {
|
||||
if (e.touches && e.touches.length > 0) {
|
||||
return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY };
|
||||
}
|
||||
if (e.changedTouches && e.changedTouches.length > 0) {
|
||||
return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY };
|
||||
}
|
||||
return { clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process data containing DEC 2026 sync markers.
|
||||
* Strips markers and returns segments that should be written atomically.
|
||||
* Each returned segment represents content between SYNC_START and SYNC_END.
|
||||
* Content outside sync blocks is returned as-is.
|
||||
*
|
||||
* @param {string} data - Raw terminal data with potential sync markers
|
||||
* @returns {string[]} - Array of content segments to write (markers stripped)
|
||||
*/
|
||||
function extractSyncSegments(data) {
|
||||
const segments = [];
|
||||
let remaining = data;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const startIdx = remaining.indexOf(DEC_SYNC_START);
|
||||
|
||||
if (startIdx === -1) {
|
||||
// No more sync blocks, return rest as-is
|
||||
if (remaining.length > 0) {
|
||||
segments.push(remaining);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Content before sync block (if any)
|
||||
if (startIdx > 0) {
|
||||
segments.push(remaining.slice(0, startIdx));
|
||||
}
|
||||
|
||||
// Find matching end marker
|
||||
const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length);
|
||||
const endIdx = afterStart.indexOf(DEC_SYNC_END);
|
||||
|
||||
if (endIdx === -1) {
|
||||
// No end marker found - sync block continues in next chunk
|
||||
// Include the start marker so it can be handled when more data arrives
|
||||
segments.push(remaining.slice(startIdx));
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract synchronized content (without markers)
|
||||
const syncContent = afterStart.slice(0, endIdx);
|
||||
if (syncContent.length > 0) {
|
||||
segments.push(syncContent);
|
||||
}
|
||||
|
||||
// Continue with content after end marker
|
||||
remaining = afterStart.slice(endIdx + DEC_SYNC_END.length);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// HTML escape utility (shared by NotificationManager, CodemanApp, and ralph-wizard.js)
|
||||
const _htmlEscapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const _htmlEscapePattern = /[&<>"']/g;
|
||||
function escapeHtml(text) {
|
||||
if (typeof text !== 'string') return '';
|
||||
return text.replace(_htmlEscapePattern, (ch) => _htmlEscapeMap[ch]);
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<script defer src="vendor/xterm-addon-fit.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
|
||||
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
|
||||
<script defer src="vendor/xterm-zerolag-input.js?v=0.2.9"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
@@ -1669,7 +1670,14 @@
|
||||
<!-- Lines drawn dynamically -->
|
||||
</svg>
|
||||
|
||||
<script defer src="app.js?v=0.1631"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.1631"></script>
|
||||
<script defer src="constants.js?v=0.2.9"></script>
|
||||
<script defer src="mobile-handlers.js?v=0.2.9"></script>
|
||||
<script defer src="voice-input.js?v=0.2.9"></script>
|
||||
<script defer src="notification-manager.js?v=0.2.9"></script>
|
||||
<script defer src="keyboard-accessory.js?v=0.2.9"></script>
|
||||
<script defer src="app.js?v=0.2.9"></script>
|
||||
<script defer src="ralph-wizard.js?v=0.2.9"></script>
|
||||
<script defer src="api-client.js?v=0.2.9"></script>
|
||||
<script defer src="subagent-windows.js?v=0.2.9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
|
||||
*/
|
||||
const KeyboardAccessoryBar = {
|
||||
element: null,
|
||||
|
||||
/** Create and inject the accessory bar */
|
||||
init() {
|
||||
// Only on mobile
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
// Create accessory bar element
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'keyboard-accessory-bar';
|
||||
this.element.innerHTML = `
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 15l7-7 7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-down" title="Arrow down">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handlers — preventDefault stops event from reaching terminal
|
||||
this.element.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.accessory-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const action = btn.dataset.action;
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
if ((action === 'scroll-up' || action === 'scroll-down') ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Insert before toolbar
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
if (toolbar && toolbar.parentNode) {
|
||||
toolbar.parentNode.insertBefore(this.element, toolbar);
|
||||
}
|
||||
},
|
||||
|
||||
_confirmTimer: null,
|
||||
_confirmAction: null,
|
||||
|
||||
/** Handle accessory button actions */
|
||||
handleAction(action, btn) {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
switch (action) {
|
||||
case 'scroll-up':
|
||||
this.sendKey('\x1b[A');
|
||||
break;
|
||||
case 'scroll-down':
|
||||
this.sendKey('\x1b[B');
|
||||
break;
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
case 'clear':
|
||||
case 'compact': {
|
||||
// Require double-tap: first tap turns amber, second tap within 2s sends
|
||||
const cmd = action === 'clear' ? '/clear' : '/compact';
|
||||
if (this._confirmAction === action && this._confirmTimer) {
|
||||
this.clearConfirm();
|
||||
this.sendCommand(cmd);
|
||||
} else {
|
||||
this.setConfirm(action, btn);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Blur active element to dismiss keyboard
|
||||
document.activeElement?.blur();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/** Enter confirm state: button turns amber for 2s waiting for second tap */
|
||||
setConfirm(action, btn) {
|
||||
this.clearConfirm();
|
||||
this._confirmAction = action;
|
||||
if (btn) {
|
||||
btn.classList.add('confirming');
|
||||
btn.dataset.origHtml = btn.innerHTML;
|
||||
btn.textContent = 'Tap again';
|
||||
}
|
||||
this._confirmTimer = setTimeout(() => this.clearConfirm(), 2000);
|
||||
},
|
||||
|
||||
/** Reset confirm state */
|
||||
clearConfirm() {
|
||||
if (this._confirmTimer) {
|
||||
clearTimeout(this._confirmTimer);
|
||||
this._confirmTimer = null;
|
||||
}
|
||||
if (this._confirmAction && this.element) {
|
||||
const btn = this.element.querySelector(`[data-action="${this._confirmAction}"]`);
|
||||
if (btn && btn.dataset.origHtml) {
|
||||
btn.innerHTML = btn.dataset.origHtml;
|
||||
delete btn.dataset.origHtml;
|
||||
}
|
||||
if (btn) btn.classList.remove('confirming');
|
||||
}
|
||||
this._confirmAction = null;
|
||||
},
|
||||
|
||||
/** Send a slash command to the active session.
|
||||
* Sends text and Enter separately so Ink processes them as distinct events. */
|
||||
sendCommand(command) {
|
||||
if (!app.activeSessionId) return;
|
||||
// Send command text first (without Enter)
|
||||
app.sendInput(command);
|
||||
// Send Enter separately after a brief delay so Ink has time to process the text.
|
||||
setTimeout(() => app.sendInput('\r'), 120);
|
||||
},
|
||||
|
||||
/** Send a special key (arrow, escape, etc.) directly to the PTY.
|
||||
* Bypasses tmux send-keys -l (literal mode) since escape sequences
|
||||
* must be written raw to be interpreted as key presses by Ink. */
|
||||
sendKey(escapeSequence) {
|
||||
if (!app.activeSessionId) return;
|
||||
fetch(`/api/sessions/${app.activeSessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: escapeSequence })
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Read clipboard and send contents as input */
|
||||
/** Show a paste overlay with a textarea for iOS compatibility */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
|
||||
// Create overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = overlay.querySelector('.paste-textarea');
|
||||
const send = () => {
|
||||
const text = textarea.value;
|
||||
overlay.remove();
|
||||
if (text) app.sendInput(text);
|
||||
};
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.paste-send').addEventListener('click', send);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
textarea.focus();
|
||||
},
|
||||
|
||||
/** Show the accessory bar */
|
||||
show() {
|
||||
if (this.element) {
|
||||
this.element.classList.add('visible');
|
||||
}
|
||||
},
|
||||
|
||||
/** Hide the accessory bar */
|
||||
hide() {
|
||||
if (this.element) {
|
||||
this.element.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Accessibility: Focus Trap for Modals
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* FocusTrap - Traps keyboard focus within an element (typically a modal).
|
||||
* Saves the previously focused element and restores focus when deactivated.
|
||||
*/
|
||||
class FocusTrap {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.previouslyFocused = null;
|
||||
this.boundHandleKeydown = this.handleKeydown.bind(this);
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.previouslyFocused = document.activeElement;
|
||||
this.element.addEventListener('keydown', this.boundHandleKeydown);
|
||||
|
||||
// Focus first focusable element after a brief delay (for CSS transitions)
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = this.getFocusableElements();
|
||||
if (focusable.length) {
|
||||
focusable[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
||||
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||
this.previouslyFocused.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getFocusableElements() {
|
||||
const selector = [
|
||||
'button:not([disabled]):not([tabindex="-1"])',
|
||||
'input:not([disabled]):not([tabindex="-1"])',
|
||||
'select:not([disabled]):not([tabindex="-1"])',
|
||||
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||
'a[href]:not([tabindex="-1"])',
|
||||
'[tabindex]:not([tabindex="-1"]):not([disabled])'
|
||||
].join(', ');
|
||||
|
||||
return [...this.element.querySelectorAll(selector)].filter(
|
||||
el => el.offsetParent !== null // Exclude hidden elements
|
||||
);
|
||||
}
|
||||
|
||||
handleKeydown(e) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const focusable = this.getFocusableElements();
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
// Codeman — Mobile detection, keyboard handling, and swipe navigation
|
||||
// Loaded after constants.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Detection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* MobileDetection - Detects device type and touch capability.
|
||||
* Updates body classes for CSS targeting.
|
||||
*/
|
||||
const MobileDetection = {
|
||||
/** Check if device supports touch input */
|
||||
isTouchDevice() {
|
||||
return 'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
||||
},
|
||||
|
||||
/** Check if device is iOS (iPhone, iPad, iPod) */
|
||||
isIOS() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
},
|
||||
|
||||
/** Check if browser is Safari */
|
||||
isSafari() {
|
||||
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
},
|
||||
|
||||
/** Check if screen is small (phone-sized, <430px) */
|
||||
isSmallScreen() {
|
||||
return window.innerWidth < 430;
|
||||
},
|
||||
|
||||
/** Check if screen is medium (tablet-sized, 430-768px) */
|
||||
isMediumScreen() {
|
||||
return window.innerWidth >= 430 && window.innerWidth < 768;
|
||||
},
|
||||
|
||||
/** Get device type based on screen width */
|
||||
getDeviceType() {
|
||||
const width = window.innerWidth;
|
||||
if (width < 430) return 'mobile';
|
||||
if (width < 768) return 'tablet';
|
||||
return 'desktop';
|
||||
},
|
||||
|
||||
/** Update body classes based on device detection */
|
||||
updateBodyClass() {
|
||||
const body = document.body;
|
||||
const deviceType = this.getDeviceType();
|
||||
const isTouch = this.isTouchDevice();
|
||||
|
||||
// Remove existing device classes
|
||||
body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser');
|
||||
|
||||
// Add current device class
|
||||
body.classList.add(`device-${deviceType}`);
|
||||
|
||||
// Add touch device class if applicable
|
||||
if (isTouch) {
|
||||
body.classList.add('touch-device');
|
||||
}
|
||||
|
||||
// Add iOS-specific class for safe area handling
|
||||
if (this.isIOS()) {
|
||||
body.classList.add('ios-device');
|
||||
}
|
||||
|
||||
// Add Safari class for browser-specific fixes
|
||||
if (this.isSafari()) {
|
||||
body.classList.add('safari-browser');
|
||||
}
|
||||
},
|
||||
|
||||
/** Initialize mobile detection and set up resize listener */
|
||||
init() {
|
||||
this.updateBodyClass();
|
||||
// Debounced resize handler
|
||||
let resizeTimeout;
|
||||
this._resizeHandler = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => this.updateBodyClass(), 100);
|
||||
};
|
||||
window.addEventListener('resize', this._resizeHandler);
|
||||
|
||||
// iOS: prevent pinch-to-zoom (Safari ignores user-scalable=no since iOS 10)
|
||||
if (this.isIOS()) {
|
||||
this._gestureStartHandler = (e) => e.preventDefault();
|
||||
this._gestureChangeHandler = (e) => e.preventDefault();
|
||||
document.addEventListener('gesturestart', this._gestureStartHandler);
|
||||
document.addEventListener('gesturechange', this._gestureChangeHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/** Remove event listeners */
|
||||
cleanup() {
|
||||
if (this._resizeHandler) {
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
this._resizeHandler = null;
|
||||
}
|
||||
if (this._gestureStartHandler) {
|
||||
document.removeEventListener('gesturestart', this._gestureStartHandler);
|
||||
document.removeEventListener('gesturechange', this._gestureChangeHandler);
|
||||
this._gestureStartHandler = null;
|
||||
this._gestureChangeHandler = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Keyboard Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears.
|
||||
* Uses focusin event and scrollIntoView - keeps it simple and reliable.
|
||||
* Also handles terminal scrolling and toolbar repositioning via visualViewport API.
|
||||
*/
|
||||
const KeyboardHandler = {
|
||||
lastViewportHeight: 0,
|
||||
keyboardVisible: false,
|
||||
initialViewportHeight: 0,
|
||||
|
||||
/** Initialize keyboard handling */
|
||||
init() {
|
||||
// Only initialize on touch devices
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
this.lastViewportHeight = this.initialViewportHeight;
|
||||
|
||||
// Simple focus handler - scroll input into view after keyboard appears
|
||||
this._focusinHandler = (e) => {
|
||||
const target = e.target;
|
||||
if (!this.isInputElement(target)) return;
|
||||
|
||||
// Wait for keyboard animation, then scroll input into view
|
||||
setTimeout(() => {
|
||||
this.scrollInputIntoView(target);
|
||||
}, 400);
|
||||
};
|
||||
document.addEventListener('focusin', this._focusinHandler);
|
||||
|
||||
// Use visualViewport to detect keyboard and reposition toolbar
|
||||
if (window.visualViewport) {
|
||||
this._viewportResizeHandler = () => {
|
||||
this.handleViewportResize();
|
||||
};
|
||||
this._viewportScrollHandler = () => {
|
||||
this.updateLayoutForKeyboard();
|
||||
};
|
||||
window.visualViewport.addEventListener('resize', this._viewportResizeHandler);
|
||||
// Also handle scroll (iOS scrolls viewport when keyboard appears)
|
||||
window.visualViewport.addEventListener('scroll', this._viewportScrollHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/** Remove event listeners */
|
||||
cleanup() {
|
||||
if (this._focusinHandler) {
|
||||
document.removeEventListener('focusin', this._focusinHandler);
|
||||
this._focusinHandler = null;
|
||||
}
|
||||
if (this._viewportResizeHandler && window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', this._viewportResizeHandler);
|
||||
this._viewportResizeHandler = null;
|
||||
}
|
||||
if (this._viewportScrollHandler && window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler);
|
||||
this._viewportScrollHandler = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Handle viewport resize (keyboard show/hide) */
|
||||
handleViewportResize() {
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight;
|
||||
const heightDiff = this.initialViewportHeight - currentHeight;
|
||||
|
||||
// Keyboard appeared (viewport shrunk by more than 150px)
|
||||
if (heightDiff > 150 && !this.keyboardVisible) {
|
||||
this.keyboardVisible = true;
|
||||
document.body.classList.add('keyboard-visible');
|
||||
this.onKeyboardShow();
|
||||
}
|
||||
// Keyboard hidden (viewport grew back close to initial)
|
||||
// Use 100px threshold (not 50) to handle iOS address bar drift,
|
||||
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
|
||||
else if (heightDiff < 100 && this.keyboardVisible) {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
}
|
||||
|
||||
// Update baseline when keyboard is not visible — adapts to address bar
|
||||
// state changes, orientation changes, and other viewport shifts
|
||||
if (!this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
}
|
||||
|
||||
this.updateLayoutForKeyboard();
|
||||
this.lastViewportHeight = currentHeight;
|
||||
},
|
||||
|
||||
/** Update layout when keyboard shows/hides */
|
||||
updateLayoutForKeyboard() {
|
||||
if (!window.visualViewport) return;
|
||||
|
||||
// Only adjust on mobile
|
||||
if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) {
|
||||
this.resetLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (this.keyboardVisible) {
|
||||
// Calculate keyboard offset
|
||||
const layoutHeight = window.innerHeight;
|
||||
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
|
||||
const keyboardOffset = layoutHeight - visualBottom;
|
||||
|
||||
// Safety: if keyboard is supposedly visible but offset is 0 or negative,
|
||||
// the keyboard is actually gone — force dismiss. This catches cases where
|
||||
// visualViewport.resize fires late or with intermediate values on iOS.
|
||||
if (keyboardOffset <= 0) {
|
||||
this.keyboardVisible = false;
|
||||
document.body.classList.remove('keyboard-visible');
|
||||
this.onKeyboardHide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move toolbar up above keyboard
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
}
|
||||
|
||||
// Move accessory bar up (it sits above toolbar)
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`;
|
||||
}
|
||||
|
||||
// Shrink main content area so terminal doesn't extend behind keyboard
|
||||
// Account for keyboard height + toolbar height (40px) + accessory bar (44px)
|
||||
if (main) {
|
||||
main.style.paddingBottom = `${keyboardOffset + 94}px`;
|
||||
}
|
||||
} else {
|
||||
this.resetLayout();
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset layout to normal (no keyboard) */
|
||||
resetLayout() {
|
||||
const toolbar = document.querySelector('.toolbar');
|
||||
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
|
||||
const main = document.querySelector('.main');
|
||||
|
||||
if (toolbar) {
|
||||
toolbar.style.transform = '';
|
||||
}
|
||||
if (accessoryBar) {
|
||||
accessoryBar.style.transform = '';
|
||||
}
|
||||
if (main) {
|
||||
main.style.paddingBottom = '';
|
||||
}
|
||||
},
|
||||
|
||||
/** Called when keyboard appears */
|
||||
onKeyboardShow() {
|
||||
// Show keyboard accessory bar
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') {
|
||||
KeyboardAccessoryBar.show();
|
||||
}
|
||||
|
||||
// Refit terminal locally AND send resize to server so Claude Code (Ink)
|
||||
// knows the actual terminal dimensions. Without this, Ink redraws at the
|
||||
// old (larger) row count when the user types, causing content to scroll
|
||||
// off the visible area with each keystroke.
|
||||
// Note: the throttledResize handler still suppresses ongoing resize events
|
||||
// while keyboard is up — this one-shot resize on open/close is sufficient.
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
if (app.fitAddon) try { app.fitAddon.fit(); } catch {}
|
||||
app.terminal.scrollToBottom();
|
||||
// Send resize to server so PTY dimensions match xterm
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
// Reposition subagent windows to stack from bottom (above keyboard)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
},
|
||||
|
||||
/** Called when keyboard hides */
|
||||
onKeyboardHide() {
|
||||
// Hide keyboard accessory bar
|
||||
if (typeof KeyboardAccessoryBar !== 'undefined') {
|
||||
KeyboardAccessoryBar.hide();
|
||||
}
|
||||
|
||||
this.resetLayout();
|
||||
|
||||
// Refit terminal, scroll to bottom, and send resize to restore original dimensions
|
||||
setTimeout(() => {
|
||||
if (typeof app !== 'undefined' && app.fitAddon) {
|
||||
try { app.fitAddon.fit(); } catch {}
|
||||
if (app.terminal) app.terminal.scrollToBottom();
|
||||
// Send resize to server to restore full terminal size
|
||||
this._sendTerminalResize();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Reposition subagent windows to stack from top (below header)
|
||||
if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
|
||||
},
|
||||
|
||||
/** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
|
||||
_sendTerminalResize() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
|
||||
try {
|
||||
const dims = app.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
const cols = Math.max(dims.cols, 40);
|
||||
const rows = Math.max(dims.rows, 10);
|
||||
app._lastResizeDims = { cols, rows };
|
||||
fetch(`/api/sessions/${app.activeSessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols, rows })
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
|
||||
/** Check if element is an input that triggers keyboard (excludes terminal) */
|
||||
isInputElement(el) {
|
||||
if (!el) return false;
|
||||
|
||||
// Exclude xterm.js terminal inputs (they handle their own scroll)
|
||||
if (el.closest('.xterm') || el.closest('.terminal-container')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagName = el.tagName?.toLowerCase();
|
||||
// Exclude type=range, type=checkbox, type=radio (don't trigger keyboard)
|
||||
if (tagName === 'input') {
|
||||
const type = el.type?.toLowerCase();
|
||||
if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
el.isContentEditable
|
||||
);
|
||||
},
|
||||
|
||||
/** Scroll input into view above the keyboard */
|
||||
scrollInputIntoView(input) {
|
||||
// Check if input is still focused (user might have tapped away)
|
||||
if (document.activeElement !== input) return;
|
||||
|
||||
// Find if we're in a modal
|
||||
const modal = input.closest('.modal.active');
|
||||
const modalBody = modal?.querySelector('.modal-body');
|
||||
|
||||
if (modalBody) {
|
||||
// For modals - scroll within the modal body
|
||||
const inputRect = input.getBoundingClientRect();
|
||||
const modalRect = modalBody.getBoundingClientRect();
|
||||
|
||||
// If input is below middle of modal, scroll it up
|
||||
if (inputRect.top > modalRect.top + modalRect.height * 0.4) {
|
||||
const scrollAmount = inputRect.top - modalRect.top - 100;
|
||||
modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
} else {
|
||||
// For page-level - use scrollIntoView
|
||||
input.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Swipe Handler
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* SwipeHandler - Detects horizontal swipes on terminal to switch sessions.
|
||||
* Only active on mobile/touch devices.
|
||||
*/
|
||||
const SwipeHandler = {
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startTime: 0,
|
||||
minSwipeDistance: 80, // Minimum pixels for a valid swipe
|
||||
maxSwipeTime: 300, // Maximum ms for a swipe gesture
|
||||
maxVerticalDrift: 100, // Max vertical movement allowed
|
||||
|
||||
/** Initialize swipe handling */
|
||||
init() {
|
||||
// Only on touch devices
|
||||
if (!MobileDetection.isTouchDevice()) return;
|
||||
|
||||
const terminal = document.querySelector('.main');
|
||||
if (!terminal) return;
|
||||
|
||||
terminal.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true });
|
||||
terminal.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true });
|
||||
},
|
||||
|
||||
onTouchStart(e) {
|
||||
if (!e.touches || e.touches.length !== 1) return;
|
||||
this.startX = e.touches[0].clientX;
|
||||
this.startY = e.touches[0].clientY;
|
||||
this.startTime = Date.now();
|
||||
},
|
||||
|
||||
onTouchEnd(e) {
|
||||
if (!e.changedTouches || e.changedTouches.length !== 1) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
|
||||
// Check if it's a valid swipe
|
||||
const deltaX = endX - this.startX;
|
||||
const deltaY = Math.abs(endY - this.startY);
|
||||
|
||||
if (elapsed > this.maxSwipeTime) return; // Too slow
|
||||
if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement
|
||||
if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short
|
||||
|
||||
// Valid swipe detected
|
||||
if (deltaX > 0) {
|
||||
// Swipe right -> previous session
|
||||
if (typeof app !== 'undefined') app.prevSession();
|
||||
} else {
|
||||
// Swipe left -> next session
|
||||
if (typeof app !== 'undefined') app.nextSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
// Codeman — Multi-layer notification system
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// Notification Manager - Multi-layer browser notification system
|
||||
class NotificationManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.isTabVisible = !document.hidden;
|
||||
this.isDrawerOpen = false;
|
||||
this.originalTitle = document.title;
|
||||
this.titleFlashInterval = null;
|
||||
this.titleFlashState = false;
|
||||
this.lastBrowserNotifTime = 0;
|
||||
this.audioCtx = null;
|
||||
this.renderScheduled = false;
|
||||
|
||||
// Debounce grouping: Map<key, {notification, timeout}>
|
||||
this.groupingMap = new Map();
|
||||
|
||||
// Load preferences
|
||||
this.preferences = this.loadPreferences();
|
||||
|
||||
// Visibility tracking
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
this.isTabVisible = !document.hidden;
|
||||
if (this.isTabVisible) {
|
||||
this.onTabVisible();
|
||||
}
|
||||
});
|
||||
// iOS Safari: pageshow fires on back-forward cache restore (bfcache)
|
||||
window.addEventListener('pageshow', (e) => {
|
||||
if (e.persisted) {
|
||||
this.isTabVisible = true;
|
||||
this.onTabVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
const defaultEventTypes = {
|
||||
permission_prompt: { enabled: true, browser: true, audio: true, push: false },
|
||||
elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
|
||||
idle_prompt: { enabled: true, browser: true, audio: false, push: false },
|
||||
stop: { enabled: true, browser: false, audio: false, push: false },
|
||||
session_error: { enabled: true, browser: true, audio: false, push: false },
|
||||
respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
|
||||
token_milestone: { enabled: true, browser: false, audio: false, push: false },
|
||||
ralph_complete: { enabled: true, browser: true, audio: true, push: false },
|
||||
subagent_spawn: { enabled: false, browser: false, audio: false, push: false },
|
||||
subagent_complete: { enabled: false, browser: false, audio: false, push: false },
|
||||
};
|
||||
|
||||
// Device-specific defaults: mobile has notifications disabled by default
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
const defaults = {
|
||||
enabled: !isMobile, // Disabled on mobile by default
|
||||
browserNotifications: !isMobile,
|
||||
audioAlerts: false,
|
||||
stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS,
|
||||
// Legacy urgency muting (keep for backwards compat)
|
||||
muteCritical: false,
|
||||
muteWarning: false,
|
||||
muteInfo: false,
|
||||
// Per-event-type preferences
|
||||
eventTypes: defaultEventTypes,
|
||||
_version: 4,
|
||||
};
|
||||
try {
|
||||
const storageKey = this.getStorageKey();
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const prefs = JSON.parse(saved);
|
||||
// Migrate: v1 had browserNotifications defaulting to false
|
||||
if (!prefs._version || prefs._version < 2) {
|
||||
prefs.browserNotifications = true;
|
||||
prefs._version = 2;
|
||||
}
|
||||
// Migrate: v2 -> v3 adds eventTypes
|
||||
if (prefs._version < 3) {
|
||||
prefs.eventTypes = defaultEventTypes;
|
||||
prefs._version = 3;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Migrate: v3 -> v4 adds push field to all eventTypes
|
||||
if (prefs._version < 4) {
|
||||
if (prefs.eventTypes) {
|
||||
for (const key of Object.keys(prefs.eventTypes)) {
|
||||
if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
|
||||
prefs.eventTypes[key].push = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefs._version = 4;
|
||||
localStorage.setItem(storageKey, JSON.stringify(prefs));
|
||||
}
|
||||
// Merge with defaults to ensure all eventTypes exist
|
||||
return {
|
||||
...defaults,
|
||||
...prefs,
|
||||
eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
|
||||
};
|
||||
}
|
||||
} catch (_e) { /* ignore */ }
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Get storage key for notification prefs (device-specific)
|
||||
getStorageKey() {
|
||||
const isMobile = MobileDetection.getDeviceType() === 'mobile';
|
||||
return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
localStorage.setItem(this.getStorageKey(), JSON.stringify(this.preferences));
|
||||
}
|
||||
|
||||
notify({ urgency, category, sessionId, sessionName, title, message }) {
|
||||
if (!this.preferences.enabled) return;
|
||||
|
||||
// Map notification categories to eventType preference keys
|
||||
const categoryToEventType = {
|
||||
'hook-permission': 'permission_prompt',
|
||||
'hook-elicitation': 'elicitation_dialog',
|
||||
'hook-idle': 'idle_prompt',
|
||||
'hook-stop': 'stop',
|
||||
'session-error': 'session_error',
|
||||
'session-crash': 'session_error',
|
||||
'session-stuck': 'idle_prompt',
|
||||
'respawn-blocked': 'respawn_cycle',
|
||||
'auto-accept': 'respawn_cycle',
|
||||
'auto-clear': 'respawn_cycle',
|
||||
'ralph-complete': 'ralph_complete',
|
||||
'circuit-breaker': 'respawn_cycle',
|
||||
'exit-gate': 'ralph_complete',
|
||||
'subagent-spawn': 'subagent_spawn',
|
||||
'subagent-complete': 'subagent_complete',
|
||||
'hook-teammate-idle': 'idle_prompt',
|
||||
'hook-task-completed': 'stop',
|
||||
};
|
||||
const eventTypeKey = categoryToEventType[category] || category;
|
||||
|
||||
// Check per-event-type preferences first
|
||||
const eventPref = this.preferences.eventTypes?.[eventTypeKey];
|
||||
let shouldBrowserNotify = false;
|
||||
let shouldAudioAlert = false;
|
||||
|
||||
if (eventPref) {
|
||||
// Event type found - use its specific preferences
|
||||
if (!eventPref.enabled) return;
|
||||
shouldBrowserNotify = eventPref.browser && this.preferences.browserNotifications;
|
||||
shouldAudioAlert = eventPref.audio && this.preferences.audioAlerts;
|
||||
} else {
|
||||
// Fall back to urgency-based muting for unknown categories
|
||||
if (urgency === 'critical' && this.preferences.muteCritical) return;
|
||||
if (urgency === 'warning' && this.preferences.muteWarning) return;
|
||||
if (urgency === 'info' && this.preferences.muteInfo) return;
|
||||
// Default browser/audio behavior based on urgency
|
||||
shouldBrowserNotify = this.preferences.browserNotifications &&
|
||||
(urgency === 'critical' || urgency === 'warning' || !this.isTabVisible);
|
||||
shouldAudioAlert = urgency === 'critical' && this.preferences.audioAlerts;
|
||||
}
|
||||
|
||||
// Grouping: same category+session within 5s updates count instead of new entry
|
||||
const groupKey = `${category}:${sessionId || 'global'}`;
|
||||
const existing = this.groupingMap.get(groupKey);
|
||||
if (existing) {
|
||||
existing.notification.count = (existing.notification.count || 1) + 1;
|
||||
existing.notification.message = message;
|
||||
existing.notification.timestamp = Date.now();
|
||||
clearTimeout(existing.timeout);
|
||||
existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
|
||||
this.scheduleRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = {
|
||||
id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
|
||||
urgency,
|
||||
category,
|
||||
sessionId,
|
||||
sessionName,
|
||||
title,
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
// Add to log (cap at 100)
|
||||
this.notifications.unshift(notification);
|
||||
if (this.notifications.length > 100) this.notifications.pop();
|
||||
|
||||
// Track for grouping
|
||||
const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS);
|
||||
this.groupingMap.set(groupKey, { notification, timeout });
|
||||
|
||||
// Update unread
|
||||
this.unreadCount++;
|
||||
this.updateBadge();
|
||||
this.scheduleRender();
|
||||
|
||||
// Layer 2: Tab title (when tab unfocused)
|
||||
if (!this.isTabVisible) {
|
||||
this.updateTabTitle();
|
||||
}
|
||||
|
||||
// Layer 3: Browser notification
|
||||
if (shouldBrowserNotify) {
|
||||
this.sendBrowserNotif(title, message, category, sessionId);
|
||||
}
|
||||
|
||||
// Layer 4: Audio alert
|
||||
if (shouldAudioAlert) {
|
||||
this.playAudioAlert();
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 1: Drawer rendering
|
||||
scheduleRender() {
|
||||
if (this.renderScheduled) return;
|
||||
this.renderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderScheduled = false;
|
||||
this.renderDrawer();
|
||||
});
|
||||
}
|
||||
|
||||
renderDrawer() {
|
||||
const list = document.getElementById('notifList');
|
||||
const empty = document.getElementById('notifEmpty');
|
||||
if (!list || !empty) return;
|
||||
|
||||
if (this.notifications.length === 0) {
|
||||
list.style.display = 'none';
|
||||
empty.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
list.style.display = 'block';
|
||||
empty.style.display = 'none';
|
||||
|
||||
list.innerHTML = this.notifications.map(n => {
|
||||
const urgencyClass = `notif-item-${n.urgency}`;
|
||||
const readClass = n.read ? '' : ' unread';
|
||||
const countLabel = n.count > 1 ? `<span class="notif-item-count">×${n.count}</span>` : '';
|
||||
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
|
||||
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
|
||||
<div class="notif-item-header">
|
||||
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
|
||||
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
|
||||
</div>
|
||||
<div class="notif-item-message">${escapeHtml(n.message)}</div>
|
||||
${sessionChip}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Layer 2: Tab title with unread count
|
||||
updateTabTitle() {
|
||||
if (this.unreadCount > 0 && !this.isTabVisible) {
|
||||
if (!this.titleFlashInterval) {
|
||||
this.titleFlashInterval = setInterval(() => {
|
||||
this.titleFlashState = !this.titleFlashState;
|
||||
document.title = this.titleFlashState
|
||||
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
|
||||
: this.originalTitle;
|
||||
}, TITLE_FLASH_INTERVAL_MS);
|
||||
// Set immediately
|
||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopTitleFlash() {
|
||||
if (this.titleFlashInterval) {
|
||||
clearInterval(this.titleFlashInterval);
|
||||
this.titleFlashInterval = null;
|
||||
this.titleFlashState = false;
|
||||
document.title = this.originalTitle;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Web Notification API
|
||||
sendBrowserNotif(title, body, tag, sessionId) {
|
||||
if (!this.preferences.browserNotifications) return;
|
||||
if (typeof Notification === 'undefined') return;
|
||||
if (Notification.permission === 'default') {
|
||||
// Auto-request on first notification attempt
|
||||
Notification.requestPermission().then(result => {
|
||||
if (result === 'granted') {
|
||||
// Re-send this notification now that we have permission
|
||||
this.sendBrowserNotif(title, body, tag, sessionId);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') return;
|
||||
|
||||
// Rate limit: max 1 per 3 seconds
|
||||
const now = Date.now();
|
||||
if (now - this.lastBrowserNotifTime < 3000) return;
|
||||
this.lastBrowserNotifTime = now;
|
||||
|
||||
const notif = new Notification(`Codeman: ${title}`, {
|
||||
body,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
silent: true, // We handle audio ourselves
|
||||
});
|
||||
|
||||
notif.onclick = () => {
|
||||
window.focus();
|
||||
if (sessionId && this.app.sessions.has(sessionId)) {
|
||||
this.app.selectSession(sessionId);
|
||||
}
|
||||
notif.close();
|
||||
};
|
||||
|
||||
// Auto-close after 8s
|
||||
setTimeout(() => notif.close(), 8000);
|
||||
}
|
||||
|
||||
async requestPermission() {
|
||||
if (typeof Notification === 'undefined') {
|
||||
this.app.showToast('Browser notifications not supported', 'warning');
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
const statusEl = document.getElementById('notifPermissionStatus');
|
||||
if (statusEl) statusEl.textContent = `Status: ${result}`;
|
||||
if (result === 'granted') {
|
||||
this.preferences.browserNotifications = true;
|
||||
this.savePreferences();
|
||||
this.app.showToast('Notifications enabled', 'success');
|
||||
} else {
|
||||
this.app.showToast(`Permission ${result}`, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: Audio alert via Web Audio API
|
||||
playAudioAlert() {
|
||||
try {
|
||||
if (!this.audioCtx) {
|
||||
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
this.audioCtx.resume();
|
||||
}
|
||||
const ctx = this.audioCtx;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.15);
|
||||
} catch (_e) { /* Audio not available */ }
|
||||
}
|
||||
|
||||
// UI interactions
|
||||
toggleDrawer() {
|
||||
const drawer = document.getElementById('notifDrawer');
|
||||
if (!drawer) return;
|
||||
this.isDrawerOpen = !this.isDrawerOpen;
|
||||
drawer.classList.toggle('open', this.isDrawerOpen);
|
||||
if (this.isDrawerOpen) {
|
||||
this.renderDrawer();
|
||||
}
|
||||
}
|
||||
|
||||
clickNotification(notifId) {
|
||||
const notif = this.notifications.find(n => n.id === notifId);
|
||||
if (!notif) return;
|
||||
|
||||
// Mark as read
|
||||
if (!notif.read) {
|
||||
notif.read = true;
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
this.updateBadge();
|
||||
}
|
||||
|
||||
// Switch to session if available
|
||||
if (notif.sessionId && this.app.sessions.has(notif.sessionId)) {
|
||||
this.app.selectSession(notif.sessionId);
|
||||
this.toggleDrawer();
|
||||
}
|
||||
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
markAllRead() {
|
||||
this.notifications.forEach(n => { n.read = true; });
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.stopTitleFlash();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.notifications = [];
|
||||
this.unreadCount = 0;
|
||||
this.updateBadge();
|
||||
this.stopTitleFlash();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
updateBadge() {
|
||||
const badge = document.getElementById('notifBadge');
|
||||
if (!badge) return;
|
||||
if (this.unreadCount > 0) {
|
||||
badge.style.display = 'flex';
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount);
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
onTabVisible() {
|
||||
this.stopTitleFlash();
|
||||
// If drawer is open, mark all as read
|
||||
if (this.isDrawerOpen) {
|
||||
this.markAllRead();
|
||||
}
|
||||
// Re-fit terminal and send resize to PTY so this client's dimensions win.
|
||||
// Fixes broken layout when switching between desktop and mobile on the same session.
|
||||
if (this.app?.fitAddon && this.app?.activeSessionId) {
|
||||
this.app.fitAddon.fit();
|
||||
this.app.sendResize(this.app.activeSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Utilities
|
||||
relativeTime(ts) {
|
||||
const diff = Math.floor((Date.now() - ts) / 1000);
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
}
|
||||
@@ -361,7 +361,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
|
||||
|
||||
// Show preview with highlighting (escape first, then apply formatting)
|
||||
const escapedPrompt = this.escapeHtml(prompt);
|
||||
const escapedPrompt = escapeHtml(prompt);
|
||||
const highlightedPrompt = escapedPrompt
|
||||
.replace(/<promise>/g, '<span class="preview-highlight"><promise>')
|
||||
.replace(/<\/promise>/g, '</promise></span>')
|
||||
@@ -724,7 +724,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<input type="checkbox" class="plan-item-checkbox" ${item.enabled ? 'checked' : ''}
|
||||
onchange="app.togglePlanItem(${index})">
|
||||
${item.priority ? `<span class="plan-item-priority-badge">${item.priority}</span>` : ''}
|
||||
<span class="plan-item-text">${this.escapeHtml(item.content)}</span>
|
||||
<span class="plan-item-text">${escapeHtml(item.content)}</span>
|
||||
`;
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
@@ -847,7 +847,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
<div class="plan-subagent-header">
|
||||
<span>
|
||||
<span class="plan-subagent-icon">${typeIcons[agentType] || '🤖'}</span>
|
||||
<span class="plan-subagent-title">${typeLabels[agentType] || this.escapeHtml(agentType)}</span>
|
||||
<span class="plan-subagent-title">${typeLabels[agentType] || escapeHtml(agentType)}</span>
|
||||
</span>
|
||||
<span class="plan-subagent-model">${model}</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,853 @@
|
||||
// Codeman — Voice input with Deepgram Nova-3 and Web Speech API fallback
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ============================================================================
|
||||
// Voice Input (Deepgram Nova-3 + Web Speech API fallback)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API.
|
||||
* Direct browser-to-Deepgram connection (no server proxy).
|
||||
* Uses MediaRecorder to capture audio and streams via WebSocket.
|
||||
*/
|
||||
const DeepgramProvider = {
|
||||
_ws: null,
|
||||
_mediaRecorder: null,
|
||||
_stream: null,
|
||||
_silenceTimeout: null,
|
||||
_keepAliveInterval: null,
|
||||
_onResult: null,
|
||||
_onError: null,
|
||||
_onEnd: null,
|
||||
|
||||
/**
|
||||
* Start streaming audio to Deepgram.
|
||||
* @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) }
|
||||
*/
|
||||
async start(opts) {
|
||||
this._onResult = opts.onResult;
|
||||
this._onError = opts.onError;
|
||||
this._onEnd = opts.onEnd;
|
||||
|
||||
// 1. Get microphone access
|
||||
try {
|
||||
this._stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err.name === 'NotAllowedError'
|
||||
? 'Microphone access denied. Check browser settings.'
|
||||
: 'Microphone error: ' + err.message;
|
||||
this._onError?.(msg);
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
// Notify caller so it can set up audio level meter
|
||||
opts.onStream?.(this._stream);
|
||||
|
||||
// 2. Detect best supported MIME type for MediaRecorder
|
||||
const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'];
|
||||
this._selectedMime = null;
|
||||
for (const mt of mimeTypes) {
|
||||
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) {
|
||||
this._selectedMime = mt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build WebSocket URL (no encoding param — Deepgram auto-detects from container format)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
model: 'nova-3',
|
||||
smart_format: 'false',
|
||||
punctuate: 'false',
|
||||
interim_results: 'true',
|
||||
utterance_end_ms: '1500',
|
||||
vad_events: 'true',
|
||||
});
|
||||
if (opts.language && opts.language !== 'multi') {
|
||||
params.set('language', opts.language);
|
||||
} else if (opts.language === 'multi') {
|
||||
params.set('detect_language', 'true');
|
||||
}
|
||||
if (opts.keyterms?.length) {
|
||||
for (const term of opts.keyterms) {
|
||||
const trimmed = term.trim();
|
||||
if (trimmed) params.append('keyterm', trimmed + ':2');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Connect WebSocket (trim API key to avoid whitespace auth failures)
|
||||
const apiKey = (opts.apiKey || '').trim();
|
||||
if (!apiKey) {
|
||||
this._onError?.('No Deepgram API key configured. Add one in Settings > Voice.');
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`;
|
||||
try {
|
||||
this._ws = new WebSocket(wsUrl, ['token', apiKey]);
|
||||
} catch (err) {
|
||||
this._onError?.('Failed to connect to Deepgram: ' + err.message);
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
// 5. Send KeepAlive every 8s to prevent Deepgram from closing idle connections
|
||||
// (covers the gap before MediaRecorder produces its first chunk)
|
||||
this._keepAliveInterval = setInterval(() => {
|
||||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.send(JSON.stringify({ type: 'KeepAlive' })); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
}, 8000);
|
||||
// 6. Start MediaRecorder once connected
|
||||
this._startRecording();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'Results' && data.channel?.alternatives?.[0]) {
|
||||
const alt = data.channel.alternatives[0];
|
||||
const transcript = alt.transcript || '';
|
||||
if (transcript) {
|
||||
const isFinal = data.is_final === true;
|
||||
this._onResult?.(transcript, isFinal);
|
||||
this._resetSilenceTimeout();
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore parse errors for non-JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
// WebSocket onerror doesn't carry useful info — onclose handles it
|
||||
};
|
||||
|
||||
this._ws.onclose = (event) => {
|
||||
clearInterval(this._keepAliveInterval);
|
||||
this._keepAliveInterval = null;
|
||||
if (event.code === 1008) {
|
||||
this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.');
|
||||
} else if (event.code === 1006) {
|
||||
// 1006 = abnormal closure (no close frame). Usually auth failure, expired key, or no credits.
|
||||
this._onError?.('Deepgram connection failed (1006). Check your API key is valid and has credits in Settings > Voice.');
|
||||
} else if (event.code !== 1000) {
|
||||
this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`));
|
||||
}
|
||||
this._stopRecording();
|
||||
this._onEnd?.();
|
||||
};
|
||||
},
|
||||
|
||||
_startRecording() {
|
||||
if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const recorderOpts = this._selectedMime ? { mimeType: this._selectedMime } : {};
|
||||
try {
|
||||
this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts);
|
||||
} catch (err) {
|
||||
this._onError?.('MediaRecorder failed: ' + err.message);
|
||||
this._cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
this._mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0 && this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this._mediaRecorder.start(250); // Send chunks every 250ms
|
||||
this._resetSilenceTimeout();
|
||||
},
|
||||
|
||||
_stopRecording() {
|
||||
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
|
||||
try { this._mediaRecorder.stop(); } catch (_e) { /* already stopped */ }
|
||||
}
|
||||
// Stop all mic tracks
|
||||
if (this._stream) {
|
||||
this._stream.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
},
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = setTimeout(() => {
|
||||
this.stop();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
stop() {
|
||||
clearTimeout(this._silenceTimeout);
|
||||
this._silenceTimeout = null;
|
||||
clearInterval(this._keepAliveInterval);
|
||||
this._keepAliveInterval = null;
|
||||
this._stopRecording();
|
||||
// Detach WS handlers before closing to prevent stale onclose from
|
||||
// killing a subsequent recording that starts before the close completes
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null;
|
||||
this._ws.onmessage = null;
|
||||
this._ws.onerror = null;
|
||||
if (this._ws.readyState === WebSocket.OPEN) {
|
||||
try { this._ws.close(1000); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
this._ws = null;
|
||||
}
|
||||
// Save onEnd before nulling — must notify VoiceInput when silence timeout
|
||||
// triggers stop internally (VoiceInput.onEnd guards with isRecording check)
|
||||
const onEnd = this._onEnd;
|
||||
this._onResult = null;
|
||||
this._onError = null;
|
||||
this._onEnd = null;
|
||||
onEnd?.();
|
||||
},
|
||||
|
||||
_cleanup() {
|
||||
this.stop();
|
||||
this._mediaRecorder = null;
|
||||
this._stream = null;
|
||||
this._selectedMime = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback).
|
||||
* Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence.
|
||||
* Shows interim transcription in a floating preview overlay.
|
||||
* Inserts final text into the active session (user presses Enter to submit).
|
||||
*/
|
||||
const VoiceInput = {
|
||||
recognition: null,
|
||||
isRecording: false,
|
||||
supported: false,
|
||||
silenceTimeout: null,
|
||||
previewEl: null,
|
||||
_lastTranscript: '',
|
||||
_stabilityTimer: null,
|
||||
_accumulatedFinal: '',
|
||||
_activeProvider: null, // 'deepgram' | 'webspeech' | null
|
||||
_recordingStartedAt: 0, // timestamp when recording started
|
||||
_retryCount: 0, // auto-retry counter for premature Web Speech API ends
|
||||
_hasReceivedResult: false, // whether any speech result came in this session
|
||||
_durationInterval: null, // timer for updating elapsed time display
|
||||
_analyser: null, // AudioContext analyser for level meter
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
_audioContext: null, // AudioContext for level meter
|
||||
_levelAnimFrame: null, // rAF handle for level meter
|
||||
|
||||
init() {
|
||||
this._initRecognition();
|
||||
// Always show buttons — if unsupported, toggle() shows a toast
|
||||
this._showButtons();
|
||||
},
|
||||
|
||||
// --- Deepgram config (localStorage only, never sent to server) ---
|
||||
|
||||
_getDeepgramConfig() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('codeman-voice-settings') || '{}');
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
_saveDeepgramConfig(config) {
|
||||
localStorage.setItem('codeman-voice-settings', JSON.stringify(config));
|
||||
},
|
||||
|
||||
_shouldUseDeepgram() {
|
||||
const cfg = this._getDeepgramConfig();
|
||||
return !!(cfg.apiKey && cfg.apiKey.trim());
|
||||
},
|
||||
|
||||
/** Get the active provider name for display */
|
||||
getActiveProviderName() {
|
||||
if (this._shouldUseDeepgram()) return 'Deepgram Nova-3';
|
||||
if (this.supported) return 'Web Speech API';
|
||||
return 'None';
|
||||
},
|
||||
|
||||
/** Try to create a SpeechRecognition instance */
|
||||
_initRecognition() {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this.supported = !!SR;
|
||||
if (!this.supported) return;
|
||||
|
||||
this.recognition = new SR();
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.lang = 'en-US';
|
||||
this.recognition.maxAlternatives = 1;
|
||||
|
||||
this.recognition.onresult = (e) => this._onWebSpeechResult(e);
|
||||
this.recognition.onerror = (e) => this._onWebSpeechError(e);
|
||||
this.recognition.onend = () => this._onWebSpeechEnd();
|
||||
},
|
||||
|
||||
toggle() {
|
||||
if (this.isRecording) {
|
||||
this.stop();
|
||||
} else {
|
||||
this.start();
|
||||
}
|
||||
},
|
||||
|
||||
start() {
|
||||
if (this.isRecording) return;
|
||||
if (!app.activeSessionId) {
|
||||
app.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
this._retryCount = 0;
|
||||
|
||||
if (this._shouldUseDeepgram()) {
|
||||
this._startDeepgram();
|
||||
} else {
|
||||
this._startWebSpeech();
|
||||
}
|
||||
},
|
||||
|
||||
_startDeepgram() {
|
||||
const cfg = this._getDeepgramConfig();
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'deepgram';
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
this._showPreview('Listening...', 'deepgram');
|
||||
this._startDurationTimer();
|
||||
|
||||
const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com')
|
||||
.split(',').map(t => t.trim()).filter(Boolean);
|
||||
|
||||
DeepgramProvider.start({
|
||||
apiKey: cfg.apiKey,
|
||||
language: cfg.language || 'en-US',
|
||||
keyterms,
|
||||
onStream: (stream) => {
|
||||
this._startLevelMeter(stream);
|
||||
},
|
||||
onResult: (text, isFinal) => {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
if (isFinal) {
|
||||
this._accumulatedFinal += text;
|
||||
this._hidePreview();
|
||||
this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
} else {
|
||||
const display = this._accumulatedFinal + text;
|
||||
this._showPreview(display, 'deepgram');
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop();
|
||||
if (wasRecording) app.showToast(msg, 'error');
|
||||
},
|
||||
onEnd: () => {
|
||||
if (this.isRecording) {
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Haptic feedback on mobile
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
|
||||
_startWebSpeech() {
|
||||
// Lazy-init: retry if recognition was cleaned up or not available at page load
|
||||
if (!this.recognition) this._initRecognition();
|
||||
if (!this.supported) {
|
||||
if (!this._shouldUseDeepgram()) {
|
||||
app.showToast('Voice input not available. Configure Deepgram in Settings > Voice.', 'warning');
|
||||
} else {
|
||||
app.showToast('Voice input not supported in this browser', 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.isRecording = true;
|
||||
this._activeProvider = 'webspeech';
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._hasReceivedResult = false;
|
||||
this._recordingStartedAt = Date.now();
|
||||
this._updateButtons('recording');
|
||||
this._showPreview('Listening...');
|
||||
this._startDurationTimer();
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (e) {
|
||||
// InvalidStateError = already started — ignore. Other errors = genuine failure.
|
||||
if (e.name !== 'InvalidStateError') {
|
||||
this.stop();
|
||||
app.showToast('Voice input failed to start: ' + e.message, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._resetSilenceTimeout();
|
||||
// Get mic stream for level meter (non-blocking — level meter is cosmetic)
|
||||
navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => {
|
||||
if (this.isRecording && this._activeProvider === 'webspeech') {
|
||||
this._webSpeechStream = stream;
|
||||
this._startLevelMeter(stream);
|
||||
} else {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
}).catch(() => { /* level meter just won't show */ });
|
||||
// Haptic feedback on mobile
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording) return;
|
||||
this.isRecording = false;
|
||||
clearTimeout(this.silenceTimeout);
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this.silenceTimeout = null;
|
||||
this._stabilityTimer = null;
|
||||
this._retryCount = 0;
|
||||
this._stopDurationTimer();
|
||||
this._stopLevelMeter();
|
||||
this._updateButtons('idle');
|
||||
this._hidePreview();
|
||||
|
||||
if (this._activeProvider === 'deepgram') {
|
||||
DeepgramProvider.stop();
|
||||
} else if (this._activeProvider === 'webspeech') {
|
||||
try {
|
||||
this.recognition?.stop();
|
||||
} catch (_e) {
|
||||
// Already stopped — ignore
|
||||
}
|
||||
// Stop the mic stream we opened for the level meter
|
||||
if (this._webSpeechStream) {
|
||||
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
||||
this._webSpeechStream = null;
|
||||
}
|
||||
}
|
||||
this._activeProvider = null;
|
||||
|
||||
// Haptic feedback on mobile
|
||||
if (navigator.vibrate) navigator.vibrate([30, 50, 30]);
|
||||
},
|
||||
|
||||
_onWebSpeechResult(event) {
|
||||
if (!this.isRecording) return;
|
||||
this._hasReceivedResult = true;
|
||||
this._resetSilenceTimeout();
|
||||
let interim = '';
|
||||
let finalText = '';
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript;
|
||||
if (event.results[i].isFinal) {
|
||||
finalText += transcript;
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
if (finalText) {
|
||||
this._accumulatedFinal += finalText;
|
||||
this._hidePreview();
|
||||
this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
} else if (interim) {
|
||||
const display = this._accumulatedFinal + interim;
|
||||
this._showPreview(display);
|
||||
// iOS Safari workaround: isFinal is always false.
|
||||
// Detect when interim results stop changing for 750ms → treat as final.
|
||||
this._iosStabilityCheck(interim);
|
||||
}
|
||||
},
|
||||
|
||||
_onWebSpeechError(event) {
|
||||
// During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them
|
||||
if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return;
|
||||
|
||||
const wasRecording = this.isRecording;
|
||||
this.stop();
|
||||
if (!wasRecording) return;
|
||||
|
||||
switch (event.error) {
|
||||
case 'not-allowed':
|
||||
app.showToast('Microphone access denied. Check browser settings.', 'error');
|
||||
break;
|
||||
case 'no-speech':
|
||||
// Silent — auto-stop is enough feedback
|
||||
break;
|
||||
case 'network':
|
||||
app.showToast('Voice input requires internet connection.', 'error');
|
||||
break;
|
||||
case 'aborted':
|
||||
// User cancelled — no message needed
|
||||
break;
|
||||
default:
|
||||
app.showToast('Voice input error: ' + event.error, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_onWebSpeechEnd() {
|
||||
// Recognition ended (browser auto-stopped or we called stop())
|
||||
if (!this.isRecording) return;
|
||||
|
||||
const elapsed = Date.now() - this._recordingStartedAt;
|
||||
// Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results).
|
||||
// Auto-retry up to 2 times to avoid the "needs two clicks" problem.
|
||||
if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) {
|
||||
this._retryCount++;
|
||||
try {
|
||||
this.recognition.start();
|
||||
} catch (_e) {
|
||||
// If restart fails, fall through to stop
|
||||
if (this._accumulatedFinal) this._insertText(this._accumulatedFinal);
|
||||
this.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Genuine end — finalize any accumulated text
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
},
|
||||
|
||||
_insertText(text) {
|
||||
if (!app.activeSessionId || !text.trim()) return;
|
||||
const trimmed = text.trim();
|
||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||
|
||||
if (mode === 'compose') {
|
||||
// If a compose overlay is already open, populate its textarea instead of recreating
|
||||
const existingTextarea = document.querySelector('.voice-compose-overlay .paste-textarea');
|
||||
if (existingTextarea) {
|
||||
existingTextarea.value = trimmed;
|
||||
existingTextarea.focus();
|
||||
existingTextarea.selectionStart = existingTextarea.selectionEnd = trimmed.length;
|
||||
} else {
|
||||
this._showComposeOverlay(trimmed);
|
||||
}
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
app._localEchoOverlay.appendText(trimmed);
|
||||
} else {
|
||||
app.sendInput(trimmed).catch(() => {});
|
||||
}
|
||||
this._showVoiceSendBtn();
|
||||
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
||||
}
|
||||
},
|
||||
|
||||
/** Show a green Enter button by transforming the gear icon in-place */
|
||||
_showVoiceSendBtn() {
|
||||
// Find the gear button (mobile or desktop header)
|
||||
const gear = document.querySelector('.btn-settings-mobile') || document.querySelector('.btn-settings');
|
||||
if (!gear || gear.classList.contains('voice-send-active')) return;
|
||||
|
||||
// Remove existing if any
|
||||
this._hideVoiceSendBtn();
|
||||
|
||||
// Save original state
|
||||
this._voiceSendGear = gear;
|
||||
this._voiceSendOriginalHTML = gear.innerHTML;
|
||||
this._voiceSendOriginalOnclick = gear.getAttribute('onclick');
|
||||
|
||||
// Transform into green send button
|
||||
gear.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||
gear.classList.add('voice-send-active');
|
||||
gear.removeAttribute('onclick');
|
||||
gear.title = 'Send (Enter)';
|
||||
|
||||
// Click handler
|
||||
this._voiceSendHandler = () => {
|
||||
if (!app.activeSessionId) return;
|
||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||
// otherwise just send \r directly to the PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
const text = app._localEchoOverlay.pendingText || '';
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection();
|
||||
if (text) app.sendInput(text).catch(() => {});
|
||||
setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
|
||||
} else {
|
||||
app.sendInput('\r').catch(() => {});
|
||||
}
|
||||
// Blink then restore
|
||||
gear.classList.add('voice-send-blink');
|
||||
setTimeout(() => this._hideVoiceSendBtn(), 400);
|
||||
};
|
||||
gear.addEventListener('click', this._voiceSendHandler);
|
||||
},
|
||||
|
||||
_hideVoiceSendBtn() {
|
||||
const gear = this._voiceSendGear;
|
||||
if (!gear) return;
|
||||
gear.removeEventListener('click', this._voiceSendHandler);
|
||||
gear.classList.remove('voice-send-active', 'voice-send-blink');
|
||||
gear.innerHTML = this._voiceSendOriginalHTML || '';
|
||||
if (this._voiceSendOriginalOnclick) {
|
||||
gear.setAttribute('onclick', this._voiceSendOriginalOnclick);
|
||||
}
|
||||
gear.title = 'App Settings';
|
||||
this._voiceSendGear = null;
|
||||
this._voiceSendHandler = null;
|
||||
this._voiceSendOriginalHTML = null;
|
||||
this._voiceSendOriginalOnclick = null;
|
||||
},
|
||||
|
||||
/** Show an editable compose overlay so the user can review/edit before sending */
|
||||
_showComposeOverlay(text) {
|
||||
document.querySelector('.voice-compose-overlay')?.remove();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'voice-compose-overlay paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea">${text.replace(/</g, '<')}</textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-new"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> New</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const textarea = overlay.querySelector('textarea');
|
||||
const send = () => {
|
||||
const val = textarea.value.trim();
|
||||
overlay.remove();
|
||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
||||
};
|
||||
const cancel = () => overlay.remove();
|
||||
const newInput = () => {
|
||||
textarea.value = '';
|
||||
textarea.blur();
|
||||
this.start();
|
||||
};
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', cancel);
|
||||
overlay.querySelector('.paste-new').addEventListener('click', newInput);
|
||||
overlay.querySelector('.paste-send').addEventListener('click', send);
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); });
|
||||
document.body.appendChild(overlay);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||
},
|
||||
|
||||
_resetSilenceTimeout() {
|
||||
clearTimeout(this.silenceTimeout);
|
||||
this.silenceTimeout = setTimeout(() => {
|
||||
if (this.isRecording) {
|
||||
// Finalize any accumulated text before stopping
|
||||
if (this._accumulatedFinal) {
|
||||
this._insertText(this._accumulatedFinal);
|
||||
}
|
||||
this.stop();
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
_iosStabilityCheck(transcript) {
|
||||
if (transcript !== this._lastTranscript) {
|
||||
this._lastTranscript = transcript;
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this._stabilityTimer = setTimeout(() => {
|
||||
if (this.isRecording) {
|
||||
const finalText = this._accumulatedFinal + transcript;
|
||||
this._hidePreview();
|
||||
this._insertText(finalText);
|
||||
this.stop();
|
||||
}
|
||||
}, 750);
|
||||
}
|
||||
},
|
||||
|
||||
_startDurationTimer() {
|
||||
this._stopDurationTimer();
|
||||
this._durationInterval = setInterval(() => {
|
||||
if (!this.isRecording || !this.previewEl) return;
|
||||
const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`;
|
||||
const timerEl = this.previewEl.querySelector('.voice-timer');
|
||||
if (timerEl) timerEl.textContent = timeStr;
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
_stopDurationTimer() {
|
||||
if (this._durationInterval) {
|
||||
clearInterval(this._durationInterval);
|
||||
this._durationInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Start audio level meter using AnalyserNode — attaches to the active mic stream */
|
||||
_startLevelMeter(stream) {
|
||||
this._stopLevelMeter();
|
||||
try {
|
||||
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this._analyserSource = this._audioContext.createMediaStreamSource(stream);
|
||||
this._analyser = this._audioContext.createAnalyser();
|
||||
this._analyser.fftSize = 256;
|
||||
this._analyserSource.connect(this._analyser);
|
||||
this._drawLevelMeter();
|
||||
} catch (_e) {
|
||||
// AudioContext not available — level meter just won't show
|
||||
}
|
||||
},
|
||||
|
||||
_stopLevelMeter() {
|
||||
if (this._levelAnimFrame) {
|
||||
cancelAnimationFrame(this._levelAnimFrame);
|
||||
this._levelAnimFrame = null;
|
||||
}
|
||||
if (this._analyserSource) {
|
||||
try { this._analyserSource.disconnect(); } catch (_e) { /* */ }
|
||||
this._analyserSource = null;
|
||||
}
|
||||
if (this._audioContext) {
|
||||
try { this._audioContext.close(); } catch (_e) { /* */ }
|
||||
this._audioContext = null;
|
||||
}
|
||||
this._analyser = null;
|
||||
},
|
||||
|
||||
_drawLevelMeter() {
|
||||
if (!this._analyser || !this.isRecording) return;
|
||||
const dataArray = new Uint8Array(this._analyser.frequencyBinCount);
|
||||
this._analyser.getByteFrequencyData(dataArray);
|
||||
// Compute RMS level 0-1
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
|
||||
const rms = Math.sqrt(sum / dataArray.length) / 255;
|
||||
// Update the level bars in the preview
|
||||
const barsEl = this.previewEl?.querySelector('.voice-level-bars');
|
||||
if (barsEl) {
|
||||
const bars = barsEl.children;
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const threshold = (i + 1) / bars.length;
|
||||
bars[i].classList.toggle('active', rms >= threshold * 0.7);
|
||||
}
|
||||
}
|
||||
this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter());
|
||||
},
|
||||
|
||||
_showPreview(text, provider) {
|
||||
if (!this.previewEl) {
|
||||
this.previewEl = document.createElement('div');
|
||||
this.previewEl.className = 'voice-preview';
|
||||
this.previewEl.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this.previewEl);
|
||||
}
|
||||
|
||||
// Build the indicator structure once, then just update the text node
|
||||
if (!this.previewEl.querySelector('.voice-recording-indicator')) {
|
||||
this.previewEl.textContent = '';
|
||||
// Recording indicator: red dot + level bars + timer
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'voice-recording-indicator';
|
||||
indicator.innerHTML = '<span class="voice-rec-dot"></span>';
|
||||
const barsEl = document.createElement('span');
|
||||
barsEl.className = 'voice-level-bars';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const bar = document.createElement('span');
|
||||
bar.className = 'voice-level-bar';
|
||||
barsEl.appendChild(bar);
|
||||
}
|
||||
indicator.appendChild(barsEl);
|
||||
const timerEl = document.createElement('span');
|
||||
timerEl.className = 'voice-timer';
|
||||
timerEl.textContent = '0:00';
|
||||
indicator.appendChild(timerEl);
|
||||
this.previewEl.appendChild(indicator);
|
||||
// Provider badge for Deepgram
|
||||
if (provider === 'deepgram') {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'voice-preview-badge';
|
||||
badge.textContent = 'DG';
|
||||
this.previewEl.appendChild(badge);
|
||||
this.previewEl.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
// Text node for transcript
|
||||
this._previewTextNode = document.createTextNode(text || 'Listening...');
|
||||
this.previewEl.appendChild(this._previewTextNode);
|
||||
} else {
|
||||
// Just update the text content
|
||||
if (this._previewTextNode) {
|
||||
this._previewTextNode.textContent = text || 'Listening...';
|
||||
}
|
||||
}
|
||||
this.previewEl.style.display = '';
|
||||
},
|
||||
|
||||
_hidePreview() {
|
||||
if (this.previewEl) {
|
||||
this.previewEl.style.display = 'none';
|
||||
this.previewEl.textContent = '';
|
||||
}
|
||||
},
|
||||
|
||||
_updateButtons(state) {
|
||||
const isRecording = state === 'recording';
|
||||
// Desktop button
|
||||
const desktopBtn = document.getElementById('voiceInputBtn');
|
||||
if (desktopBtn) {
|
||||
desktopBtn.classList.toggle('recording', isRecording);
|
||||
desktopBtn.setAttribute('aria-pressed', String(isRecording));
|
||||
desktopBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
|
||||
desktopBtn.title = isRecording ? 'Stop voice input (Ctrl+Shift+V)' : 'Voice input (Ctrl+Shift+V)';
|
||||
}
|
||||
// Mobile toolbar button (always visible on mobile)
|
||||
const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
|
||||
if (mobileToolbarBtn) {
|
||||
mobileToolbarBtn.classList.toggle('recording', isRecording);
|
||||
mobileToolbarBtn.setAttribute('aria-pressed', String(isRecording));
|
||||
mobileToolbarBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input');
|
||||
}
|
||||
},
|
||||
|
||||
_showButtons() {
|
||||
const desktopBtn = document.getElementById('voiceInputBtn');
|
||||
if (desktopBtn) desktopBtn.style.display = '';
|
||||
const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile');
|
||||
if (mobileToolbarBtn) mobileToolbarBtn.style.display = '';
|
||||
},
|
||||
|
||||
/** Cleanup on SSE reconnect or page unload */
|
||||
cleanup() {
|
||||
if (this.isRecording) this.stop();
|
||||
this._hideVoiceSendBtn();
|
||||
DeepgramProvider._cleanup();
|
||||
this.recognition = null;
|
||||
this._activeProvider = null;
|
||||
this._stopDurationTimer();
|
||||
this._stopLevelMeter();
|
||||
if (this._webSpeechStream) {
|
||||
this._webSpeechStream.getTracks().forEach(t => t.stop());
|
||||
this._webSpeechStream = null;
|
||||
}
|
||||
if (this.previewEl) {
|
||||
this.previewEl.remove();
|
||||
this.previewEl = null;
|
||||
}
|
||||
clearTimeout(this.silenceTimeout);
|
||||
clearTimeout(this._stabilityTimer);
|
||||
this.silenceTimeout = null;
|
||||
this._stabilityTimer = null;
|
||||
this._accumulatedFinal = '';
|
||||
this._lastTranscript = '';
|
||||
this._retryCount = 0;
|
||||
this._hasReceivedResult = false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user