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>
@@ -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).
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 214 KiB |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||