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;