diff --git a/CLAUDE.md b/CLAUDE.md index 512dec6d..d476fb47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1461 (must match `package.json` for npm publish) +**Version**: 0.1462 (must match `package.json` for npm publish) ## Project Overview @@ -177,7 +177,7 @@ To change defaults, edit the `??` fallback values in `openAppSettings()` and `ap **Port allocation**: E2E tests use centralized ports in `test/e2e/e2e.config.ts`. Unit/integration tests pick unique ports manually. Search `const PORT =` or `TEST_PORT` in test files to find used ports before adding new tests. -**E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3190), timeouts, and helpers. +**E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3193), timeouts, and helpers. **Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). diff --git a/docs/screenshots/mobile-keyboard-visible.png b/docs/screenshots/mobile-keyboard-visible.png index eeeb9e24..f9187c63 100644 Binary files a/docs/screenshots/mobile-keyboard-visible.png and b/docs/screenshots/mobile-keyboard-visible.png differ diff --git a/docs/screenshots/mobile-no-keyboard.png b/docs/screenshots/mobile-no-keyboard.png index b2172a90..b9dc2593 100644 Binary files a/docs/screenshots/mobile-no-keyboard.png and b/docs/screenshots/mobile-no-keyboard.png differ diff --git a/docs/screenshots/mobile-toolbar-above-keyboard.png b/docs/screenshots/mobile-toolbar-above-keyboard.png index ec60fa9a..a41834ac 100644 Binary files a/docs/screenshots/mobile-toolbar-above-keyboard.png and b/docs/screenshots/mobile-toolbar-above-keyboard.png differ diff --git a/docs/screenshots/mobile-with-keyboard.png b/docs/screenshots/mobile-with-keyboard.png index 71c04141..cd9fae9e 100644 Binary files a/docs/screenshots/mobile-with-keyboard.png and b/docs/screenshots/mobile-with-keyboard.png differ diff --git a/package.json b/package.json index aef5c22e..d956b8c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1461", + "version": "0.1462", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/mobile-screenshot.mjs b/scripts/mobile-screenshot.mjs index 6e224776..25f3f4ea 100644 --- a/scripts/mobile-screenshot.mjs +++ b/scripts/mobile-screenshot.mjs @@ -58,13 +58,7 @@ async function takeScreenshots() { // Screenshot 2: Simulate keyboard appearing console.log('Simulating virtual keyboard...'); - // Resize viewport to simulate keyboard taking up space - await page.setViewportSize({ - width: VIEWPORT_WIDTH, - height: VIEWPORT_HEIGHT - KEYBOARD_HEIGHT - }); - - // Trigger the keyboard handler manually + // Trigger the keyboard handler manually - simulate what happens when keyboard shows await page.evaluate((keyboardHeight) => { // Add keyboard-visible class document.body.classList.add('keyboard-visible'); @@ -74,6 +68,20 @@ async function takeScreenshots() { if (toolbar) { toolbar.style.transform = `translateY(${-keyboardHeight}px)`; } + + // Show and move accessory bar + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + if (accessoryBar) { + accessoryBar.classList.add('visible'); + accessoryBar.style.transform = `translateY(${-keyboardHeight}px)`; + } + + // Shrink main content area so terminal doesn't extend behind keyboard + // Account for toolbar (40px) + accessory bar (44px) + const main = document.querySelector('.main'); + if (main) { + main.style.paddingBottom = `${keyboardHeight + 94}px`; + } }, KEYBOARD_HEIGHT); await page.waitForTimeout(300); diff --git a/src/web/public/app.js b/src/web/public/app.js index 00d59bc8..30ea8dfe 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -218,7 +218,7 @@ const KeyboardHandler = { }); // Also handle scroll (iOS scrolls viewport when keyboard appears) window.visualViewport.addEventListener('scroll', () => { - this.updateToolbarPosition(); + this.updateLayoutForKeyboard(); }); } }, @@ -241,40 +241,74 @@ const KeyboardHandler = { this.onKeyboardHide(); } - this.updateToolbarPosition(); + this.updateLayoutForKeyboard(); this.lastViewportHeight = currentHeight; }, - /** Update toolbar position based on visual viewport */ - updateToolbarPosition() { + /** Update layout when keyboard shows/hides */ + updateLayoutForKeyboard() { if (!window.visualViewport) return; - const toolbar = document.querySelector('.toolbar'); - if (!toolbar) return; - - // Only reposition on mobile + // Only adjust on mobile if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) { - toolbar.style.transform = ''; + this.resetLayout(); return; } + const toolbar = document.querySelector('.toolbar'); + const accessoryBar = document.querySelector('.keyboard-accessory-bar'); + const main = document.querySelector('.main'); + if (this.keyboardVisible) { - // The toolbar is fixed at bottom: 0 (relative to layout viewport) - // We need to move it up to be at the bottom of the visual viewport - // Distance from visual viewport bottom to layout viewport bottom: + // Calculate keyboard offset const layoutHeight = window.innerHeight; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; - const offsetFromBottom = layoutHeight - visualBottom; + const keyboardOffset = layoutHeight - visualBottom; - // Move toolbar up by this offset (negative translateY moves up) - toolbar.style.transform = `translateY(${-offsetFromBottom}px)`; + // 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(); + } + // Scroll active terminal to bottom so input line is visible setTimeout(() => { if (typeof app !== 'undefined' && app.activeSessionId) { @@ -288,11 +322,12 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { - // Reset toolbar position - const toolbar = document.querySelector('.toolbar'); - if (toolbar) { - toolbar.style.transform = ''; + // Hide keyboard accessory bar + if (typeof KeyboardAccessoryBar !== 'undefined') { + KeyboardAccessoryBar.hide(); } + + this.resetLayout(); }, /** Check if element is an input that triggers keyboard (excludes terminal) */ @@ -345,6 +380,155 @@ const KeyboardHandler = { } }; +// ============================================================================ +// 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(); + } + } +}; + +// ============================================================================ +// 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 = ` + + + + + `; + + // Add click handlers + this.element.addEventListener('click', (e) => { + const btn = e.target.closest('.accessory-btn'); + if (!btn) return; + + const action = btn.dataset.action; + this.handleAction(action); + }); + + // Insert before toolbar + const toolbar = document.querySelector('.toolbar'); + if (toolbar && toolbar.parentNode) { + toolbar.parentNode.insertBefore(this.element, toolbar); + } + }, + + /** Handle accessory button actions */ + handleAction(action) { + if (typeof app === 'undefined' || !app.activeSessionId) return; + + switch (action) { + case 'init': + this.confirmAndSend('/init', 'Run /init command?'); + break; + case 'clear': + this.confirmAndSend('/clear', 'Clear conversation history?'); + break; + case 'compact': + this.confirmAndSend('/compact', 'Compact context?'); + break; + case 'dismiss': + // Blur active element to dismiss keyboard + document.activeElement?.blur(); + break; + } + }, + + /** Show confirmation dialog before sending command */ + confirmAndSend(command, message) { + if (confirm(message)) { + app.sendInput(command + '\r'); + } + }, + + /** 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 // ============================================================================ @@ -1051,8 +1235,10 @@ class ClaudemanApp { init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); - // Initialize keyboard handler for mobile input visibility + // Initialize mobile handlers KeyboardHandler.init(); + SwipeHandler.init(); + KeyboardAccessoryBar.init(); this.initTerminal(); this.loadFontSize(); this.applyHeaderVisibilitySettings(); @@ -3515,6 +3701,14 @@ class ClaudemanApp { this.selectSession(this.sessionOrder[nextIndex]); } + prevSession() { + if (this.sessionOrder.length <= 1) return; + + const currentIndex = this.sessionOrder.indexOf(this.activeSessionId); + const prevIndex = (currentIndex - 1 + this.sessionOrder.length) % this.sessionOrder.length; + this.selectSession(this.sessionOrder[prevIndex]); + } + // ========== Navigation ========== goHome() { @@ -7420,6 +7614,20 @@ class ClaudemanApp { }); } + /** + * Send input to the active session. + * @param {string} input - Text to send (include \r for Enter) + * @returns {Promise} + */ + async sendInput(input) { + if (!this.activeSessionId) return; + await fetch(`/api/sessions/${this.activeSessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input, useScreen: true }) + }); + } + // ========== Timer ========== showTimer() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index d3532352..4830103e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8174,35 +8174,107 @@ kbd { color: #fff; } - /* Case selector - fills available space on left */ + /* Hide case selector on mobile - simplified toolbar */ .case-select-group { - flex: 1; - display: flex; - align-items: center; - gap: 4px; - order: 1; /* Left position */ - min-width: 0; + display: none !important; } - .toolbar-select { - font-size: 0.65rem !important; - padding: 0 1.4rem 0 0.4rem !important; - min-height: 26px !important; - max-height: 26px !important; - height: 26px !important; - line-height: 26px; - border-radius: 4px; + /* Simplified toolbar layout - just Run Claude and Run Shell */ + .toolbar-left .toolbar-group:first-child { + width: 100%; + justify-content: center; + gap: 12px; + } + + .btn-toolbar.btn-claude { + flex: 0 0 auto; + min-width: fit-content; + white-space: nowrap; + padding: 0 12px !important; + } + + .btn-toolbar.btn-shell { + flex: 0 0 auto; + min-width: fit-content; + white-space: nowrap; + padding: 0 12px !important; + } + + /* Keyboard Accessory Bar - quick actions above keyboard */ + .keyboard-accessory-bar { + display: none; + position: fixed; + bottom: calc(var(--safe-area-bottom) + 40px); /* Above toolbar */ + left: 0; + right: 0; + height: 44px; background: #1a1a1a; - border: 1px solid rgba(255, 255, 255, 0.15); - color: #e5e5e5; - flex: 1; - min-width: 0; - text-overflow: ellipsis; - overflow: hidden; + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding: 6px 8px; + padding-left: calc(8px + var(--safe-area-left)); + padding-right: calc(8px + var(--safe-area-right)); + gap: 8px; + align-items: center; + justify-content: center; + z-index: 51; + transition: transform 0.15s ease-out; + will-change: transform; } - /* Add case button - compact */ + .keyboard-accessory-bar.visible { + display: flex; + } + + .accessory-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 6px 12px; + background: #2a2a2a; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #e5e5e5; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: background 0.1s; + } + + .accessory-btn:active { + background: #3a3a3a; + } + + .accessory-btn svg { + width: 14px; + height: 14px; + } + + .accessory-btn-dismiss { + margin-left: auto; + padding: 6px 10px; + background: transparent; + border-color: transparent; + color: #888; + } + + /* Add case button - compact (keeping for when shown via menu) */ .btn-case-add { + display: none !important; + } + + /* When keyboard is visible, also move accessory bar up */ + .keyboard-visible .keyboard-accessory-bar.visible { + /* Position is handled by JS transform along with toolbar */ + } + + /* LEGACY: Hide old toolbar select (no longer used on mobile) */ + .toolbar-select { + display: none !important; + } + + /* Keep btn-case-add styling for tablet/future use */ + .toolbar .btn-case-add { min-width: 26px !important; max-width: 26px !important; width: 26px !important;