fix: mobile toolbar button sizing and accessory bar commands

- Fix Run Claude button being squeezed on mobile (use fit-content width)
- Remove Send button from accessory bar
- Add /init, /clear, /compact buttons with confirmation dialogs
- Center toolbar buttons instead of stretching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-31 14:46:26 +01:00
co-authored by Claude Opus 4.5
parent b655d2e29e
commit d70e2d1d93
9 changed files with 340 additions and 52 deletions
+2 -2
View File
@@ -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).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 214 KiB

+1 -1
View File
@@ -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",
+15 -7
View File
@@ -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);
+228 -20
View File
@@ -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 = `
<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 accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 9l-7 7-7-7"/>
</svg>
</button>
`;
// 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<void>}
*/
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() {
+94 -22
View File
@@ -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;