From 28b175c74a48e2d9c9eafdf665aa16e23b626904 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 17 Feb 2026 07:08:26 +0100 Subject: [PATCH] chore: bump version to 0.1521 --- CLAUDE.md | 2 +- mobile-test.mjs | 174 -------------------------------------- package.json | 2 +- src/web/public/app.js | 144 +++++++++++++++++++++++-------- src/web/public/mobile.css | 69 +++++++-------- 5 files changed, 144 insertions(+), 247 deletions(-) delete mode 100644 mobile-test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 628902c4..15419e99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version 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.1520 (must match `package.json` for npm publish) +**Version**: 0.1521 (must match `package.json` for npm publish) ## Project Overview diff --git a/mobile-test.mjs b/mobile-test.mjs deleted file mode 100644 index ec18e851..00000000 --- a/mobile-test.mjs +++ /dev/null @@ -1,174 +0,0 @@ -import { chromium } from 'playwright'; - -const BASE = 'http://localhost:3000'; - -// iPhone 14 Pro viewport -const MOBILE_VP = { width: 393, height: 852 }; -// iPad viewport -const TABLET_VP = { width: 768, height: 1024 }; - -async function testMobile(page, label, vp) { - await page.setViewportSize(vp); - await page.goto(BASE, { waitUntil: 'domcontentloaded' }); - await page.waitForTimeout(2000); - - const results = []; - - // 1. Check settings modal renders and tabs are visible - // Open settings via mobile toolbar button or header button - const settingsBtn = await page.$('.btn-settings-mobile') || await page.$('.btn-settings'); - if (settingsBtn) { - await settingsBtn.click(); - await page.waitForTimeout(500); - - const modal = await page.$('#appSettingsModal.active'); - results.push({ test: `${label}: Settings modal opens`, pass: !!modal }); - - if (modal) { - // Check tab buttons are visible and not overflowing - const tabs = await page.$$('#appSettingsModal .modal-tab-btn'); - results.push({ test: `${label}: Settings has ${tabs.length} tabs`, pass: tabs.length === 5 }); - - // Check first tab (Display) content is visible - const displayTab = await page.$('#settings-display'); - const displayVisible = displayTab ? await displayTab.isVisible() : false; - results.push({ test: `${label}: Display tab content visible`, pass: displayVisible }); - - // Check settings-grid layout - const settingsGrid = await page.$('#settings-display .settings-grid'); - if (settingsGrid) { - const gridStyle = await settingsGrid.evaluate(el => { - const cs = window.getComputedStyle(el); - return cs.gridTemplateColumns; - }); - const isSingleCol = vp.width < 430; - if (isSingleCol) { - // On phone, should be single column - const colCount = gridStyle.split(' ').length; - results.push({ test: `${label}: Settings grid is single-col (${colCount} cols)`, pass: colCount === 1 }); - } else { - results.push({ test: `${label}: Settings grid columns = ${gridStyle}`, pass: true }); - } - } - - // Switch to notifications tab and check event grid - const notifTab = await page.$('[data-tab="settings-notifications"]'); - if (notifTab) { - await notifTab.click(); - await page.waitForTimeout(300); - const eventGrid = await page.$('.event-type-grid'); - if (eventGrid) { - const eventGridVisible = await eventGrid.isVisible(); - results.push({ test: `${label}: Event type grid visible`, pass: eventGridVisible }); - - const gridCols = await eventGrid.evaluate(el => { - return window.getComputedStyle(el).gridTemplateColumns; - }); - results.push({ test: `${label}: Event grid cols = ${gridCols}`, pass: true }); - } - } - - // Check modal tab scrollability - const tabsContainer = await page.$('#appSettingsModal .modal-tabs'); - if (tabsContainer) { - const overflow = await tabsContainer.evaluate(el => { - const cs = window.getComputedStyle(el); - return { overflowX: cs.overflowX, flexWrap: cs.flexWrap }; - }); - results.push({ test: `${label}: Tab bar overflow-x=${overflow.overflowX}, flex-wrap=${overflow.flexWrap}`, pass: overflow.overflowX === 'auto' || overflow.flexWrap === 'nowrap' }); - } - - // Close modal - const closeBtn = await page.$('#appSettingsModal .modal-close'); - if (closeBtn) await closeBtn.click(); - await page.waitForTimeout(300); - } - } else { - results.push({ test: `${label}: Settings button found`, pass: false }); - } - - // 2. Check subagent badge sizing - // We can check the CSS computed style for .tab-subagent-badge if any exist - const badges = await page.$$('.tab-subagent-badge'); - if (badges.length > 0) { - const badgeHeight = await badges[0].evaluate(el => { - return parseFloat(window.getComputedStyle(el).height); - }); - if (vp.width < 430) { - results.push({ test: `${label}: Subagent badge height=${badgeHeight}px (expect ~14)`, pass: badgeHeight <= 16 }); - } else { - results.push({ test: `${label}: Subagent badge height=${badgeHeight}px`, pass: true }); - } - } else { - results.push({ test: `${label}: No subagent badges to check (OK)`, pass: true }); - } - - // 3. Check subagent windows if any are open - const subWindows = await page.$$('.subagent-window'); - if (subWindows.length > 0 && vp.width < 430) { - for (const sw of subWindows) { - const swStyle = await sw.evaluate(el => { - const cs = window.getComputedStyle(el); - return { - position: cs.position, - width: cs.width, - borderRadius: cs.borderRadius, - }; - }); - results.push({ test: `${label}: Subagent window position=${swStyle.position}, width=${swStyle.width}`, pass: swStyle.position === 'fixed' }); - } - } - - // 4. Check header height - const header = await page.$('.header'); - if (header) { - const headerHeight = await header.evaluate(el => { - return parseFloat(window.getComputedStyle(el).height); - }); - const expectedMax = vp.width < 430 ? 40 : 55; - results.push({ test: `${label}: Header height=${headerHeight}px (max ${expectedMax})`, pass: headerHeight <= expectedMax }); - } - - // 5. Check toolbar is fixed at bottom on mobile - if (vp.width < 430) { - const toolbar = await page.$('.toolbar'); - if (toolbar) { - const toolbarPos = await toolbar.evaluate(el => { - return window.getComputedStyle(el).position; - }); - results.push({ test: `${label}: Toolbar position=${toolbarPos}`, pass: toolbarPos === 'fixed' }); - } - } - - return results; -} - -(async () => { - const browser = await chromium.launch({ headless: true }); - - try { - // Test mobile viewport - const mobilePage = await browser.newPage(); - const mobileResults = await testMobile(mobilePage, 'Phone (393px)', MOBILE_VP); - await mobilePage.close(); - - // Test tablet viewport - const tabletPage = await browser.newPage(); - const tabletResults = await testMobile(tabletPage, 'Tablet (768px)', TABLET_VP); - await tabletPage.close(); - - const all = [...mobileResults, ...tabletResults]; - const passed = all.filter(r => r.pass).length; - const failed = all.filter(r => !r.pass).length; - - console.log('\n=== Mobile Optimization Test Results ===\n'); - for (const r of all) { - console.log(` ${r.pass ? '✓' : '✗'} ${r.test}`); - } - console.log(`\n ${passed} passed, ${failed} failed out of ${all.length} tests\n`); - - if (failed > 0) process.exit(1); - } finally { - await browser.close(); - } -})(); diff --git a/package.json b/package.json index 41f824fe..a28a5229 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1520", + "version": "0.1521", "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/src/web/public/app.js b/src/web/public/app.js index 40162218..f46ef22e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -6007,6 +6007,11 @@ class ClaudemanApp { if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.touchMove) { + document.removeEventListener('touchmove', windowData.dragListeners.touchMove); + document.removeEventListener('touchend', windowData.dragListeners.up); + document.removeEventListener('touchcancel', windowData.dragListeners.up); + } } if (windowData.element) { windowData.element.remove(); @@ -12287,8 +12292,10 @@ class ClaudemanApp { // Calculate final position - grid layout to avoid overlaps const windowCount = this.subagentWindows.size; const isMobile = MobileDetection.getDeviceType() === 'mobile'; + const mobileCardHeight = 110; + const mobileCardGap = 4; const windowWidth = isMobile ? window.innerWidth : 420; - const windowHeight = isMobile ? Math.round(window.innerHeight * 0.4) : 350; + const windowHeight = isMobile ? mobileCardHeight : 350; const gap = 20; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; @@ -12297,16 +12304,16 @@ class ClaudemanApp { let finalY = 0; if (isMobile) { - // Mobile: CSS handles full-width bottom-sheet positioning via .subagent-window rules. - // Only one window visible at a time on mobile — new window replaces the old. - // Minimize all existing windows first so only the latest is visible. - for (const [existingId, existingData] of this.subagentWindows) { - if (!existingData.minimized) { - this.minimizeSubagentWindow(existingId); - } + // Mobile: stack compact cards above toolbar. Count visible (non-minimized) windows. + let visibleCount = 0; + for (const [, data] of this.subagentWindows) { + if (!data.minimized && !data.hidden) visibleCount++; } - finalX = 0; - finalY = viewportHeight - windowHeight - 40; // toolbar height + const toolbarHeight = 40; + // Stack from bottom: first card just above toolbar, next above that, etc. + const bottomOffset = toolbarHeight + visibleCount * (mobileCardHeight + mobileCardGap); + finalX = 4; + finalY = viewportHeight - bottomOffset - mobileCardHeight; } else { // Check if Ralph wizard modal is open - if so, position windows on the sides const wizardModal = document.getElementById('ralphWizardModal'); @@ -12419,25 +12426,32 @@ class ClaudemanApp { `; // If we have a parent tab, start window at tab position for spawn animation - // On mobile, skip spawn animation — CSS handles bottom-sheet positioning - if (parentTab && !isMobile) { + if (isMobile) { + // Mobile: position as stacked card via inline bottom offset + let visibleCount = 0; + for (const [, data] of this.subagentWindows) { + if (!data.minimized && !data.hidden) visibleCount++; + } + const bottomPx = 40 + visibleCount * (mobileCardHeight + mobileCardGap); + win.style.bottom = `${bottomPx}px`; + win.style.top = 'auto'; + } else if (parentTab) { const tabRect = parentTab.getBoundingClientRect(); win.style.left = `${tabRect.left}px`; win.style.top = `${tabRect.bottom}px`; win.style.transform = 'scale(0.3)'; win.style.opacity = '0'; win.classList.add('spawning'); - } else if (!isMobile) { + } else { // No parent tab, just position normally (desktop/tablet) win.style.left = `${finalX}px`; win.style.top = `${finalY}px`; } - // On mobile: CSS positions via fixed bottom-sheet rules, no inline positioning needed document.body.appendChild(win); - // Make draggable (returns listener refs for cleanup) — skip on mobile (bottom-sheet) - const dragListeners = isMobile ? null : this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + // Make draggable (returns listener refs for cleanup) + const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); // Check if this window should be visible based on settings // Use the PERSISTENT parent map for accurate tab-based visibility @@ -12588,6 +12602,11 @@ class ClaudemanApp { if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.touchMove) { + document.removeEventListener('touchmove', windowData.dragListeners.touchMove); + document.removeEventListener('touchend', windowData.dragListeners.up); + document.removeEventListener('touchcancel', windowData.dragListeners.up); + } } windowData.element.remove(); this.subagentWindows.delete(agentId); @@ -12615,6 +12634,11 @@ class ClaudemanApp { if (windowData.dragListeners) { document.removeEventListener('mousemove', windowData.dragListeners.move); document.removeEventListener('mouseup', windowData.dragListeners.up); + if (windowData.dragListeners.touchMove) { + document.removeEventListener('touchmove', windowData.dragListeners.touchMove); + document.removeEventListener('touchend', windowData.dragListeners.up); + document.removeEventListener('touchcancel', windowData.dragListeners.up); + } } windowData.element.remove(); } @@ -12638,6 +12662,11 @@ class ClaudemanApp { if (data.dragListeners) { document.removeEventListener('mousemove', data.dragListeners.move); document.removeEventListener('mouseup', data.dragListeners.up); + if (data.dragListeners.touchMove) { + document.removeEventListener('touchmove', data.dragListeners.touchMove); + document.removeEventListener('touchend', data.dragListeners.up); + document.removeEventListener('touchcancel', data.dragListeners.up); + } } data.element.remove(); } @@ -12676,6 +12705,11 @@ class ClaudemanApp { if (popupData.dragListeners) { document.removeEventListener('mousemove', popupData.dragListeners.move); document.removeEventListener('mouseup', popupData.dragListeners.up); + if (popupData.dragListeners.touchMove) { + document.removeEventListener('touchmove', popupData.dragListeners.touchMove); + document.removeEventListener('touchend', popupData.dragListeners.up); + document.removeEventListener('touchcancel', popupData.dragListeners.up); + } } popupData.element.remove(); } @@ -12709,6 +12743,11 @@ class ClaudemanApp { if (this.teamTasksDragListeners) { document.removeEventListener('mousemove', this.teamTasksDragListeners.move); document.removeEventListener('mouseup', this.teamTasksDragListeners.up); + if (this.teamTasksDragListeners.touchMove) { + document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove); + document.removeEventListener('touchend', this.teamTasksDragListeners.up); + document.removeEventListener('touchcancel', this.teamTasksDragListeners.up); + } this.teamTasksDragListeners = null; } @@ -12772,28 +12811,27 @@ class ClaudemanApp { let startX, startY, startLeft, startTop; let dragUpdateScheduled = false; - handle.addEventListener('mousedown', (e) => { - if (e.target.tagName === 'BUTTON') return; + const startDrag = (clientX, clientY) => { isDragging = true; - startX = e.clientX; - startY = e.clientY; - startLeft = parseInt(win.style.left) || 0; - startTop = parseInt(win.style.top) || 0; - e.preventDefault(); - }); + startX = clientX; + startY = clientY; + startLeft = parseInt(win.style.left) || win.getBoundingClientRect().left; + startTop = parseInt(win.style.top) || win.getBoundingClientRect().top; + // On drag start, switch from bottom-positioned to top-positioned so left/top work + win.style.bottom = 'auto'; + }; - // Store references to document-level listeners so they can be removed on window close - const moveListener = (e) => { + const moveDrag = (clientX, clientY) => { if (!isDragging) return; - const dx = e.clientX - startX; - const dy = e.clientY - startY; + const dx = clientX - startX; + const dy = clientY - startY; // Constrain to viewport bounds const winWidth = win.offsetWidth || 420; const winHeight = win.offsetHeight || 350; - const maxX = window.innerWidth - winWidth - 10; - const maxY = window.innerHeight - winHeight - 10; - const newLeft = Math.max(10, Math.min(startLeft + dx, maxX)); - const newTop = Math.max(10, Math.min(startTop + dy, maxY)); + const maxX = window.innerWidth - winWidth - 4; + const maxY = window.innerHeight - winHeight - 4; + const newLeft = Math.max(4, Math.min(startLeft + dx, maxX)); + const newTop = Math.max(4, Math.min(startTop + dy, maxY)); win.style.left = `${newLeft}px`; win.style.top = `${newTop}px`; // Throttle connection line updates during drag @@ -12806,7 +12844,7 @@ class ClaudemanApp { } }; - const upListener = () => { + const endDrag = () => { if (isDragging) { isDragging = false; // Save position after drag ends @@ -12814,11 +12852,44 @@ class ClaudemanApp { } }; + // Mouse events + handle.addEventListener('mousedown', (e) => { + if (e.target.tagName === 'BUTTON') return; + startDrag(e.clientX, e.clientY); + e.preventDefault(); + }); + + // Touch events + handle.addEventListener('touchstart', (e) => { + if (e.target.tagName === 'BUTTON') return; + const touch = e.touches[0]; + startDrag(touch.clientX, touch.clientY); + }, { passive: true }); + + // Store references to document-level listeners so they can be removed on window close + const moveListener = (e) => { + moveDrag(e.clientX, e.clientY); + }; + + const touchMoveListener = (e) => { + if (!isDragging) return; + e.preventDefault(); // Prevent page scroll while dragging + const touch = e.touches[0]; + moveDrag(touch.clientX, touch.clientY); + }; + + const upListener = () => { + endDrag(); + }; + document.addEventListener('mousemove', moveListener); document.addEventListener('mouseup', upListener); + document.addEventListener('touchmove', touchMoveListener, { passive: false }); + document.addEventListener('touchend', upListener); + document.addEventListener('touchcancel', upListener); // Return listener references for cleanup - return { move: moveListener, up: upListener }; + return { move: moveListener, up: upListener, touchMove: touchMoveListener }; } renderSubagentWindowContent(agentId) { @@ -13355,6 +13426,11 @@ class ClaudemanApp { if (this.teamTasksDragListeners) { document.removeEventListener('mousemove', this.teamTasksDragListeners.move); document.removeEventListener('mouseup', this.teamTasksDragListeners.up); + if (this.teamTasksDragListeners.touchMove) { + document.removeEventListener('touchmove', this.teamTasksDragListeners.touchMove); + document.removeEventListener('touchend', this.teamTasksDragListeners.up); + document.removeEventListener('touchcancel', this.teamTasksDragListeners.up); + } this.teamTasksDragListeners = null; } } diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e720efb9..292d1235 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1211,22 +1211,18 @@ html.mobile-init .file-browser-panel { /* ---- Subagent Windows: Mobile Optimizations ---- */ - /* Full-width stacked windows at bottom on phones */ + /* Compact stacked cards — multiple visible at once, draggable via touch */ .subagent-window { - position: fixed !important; - left: 0 !important; - right: 0 !important; - width: 100% !important; - max-width: 100% !important; - height: 40vh !important; - min-height: 150px !important; - max-height: 50vh !important; - bottom: calc(40px + var(--safe-area-bottom)) !important; - top: auto !important; - border-radius: 8px 8px 0 0; + position: fixed; + width: calc(100% - 8px); + max-width: calc(100% - 8px); + height: 110px; + min-height: 80px; + max-height: 110px; + min-width: 200px; + border-radius: 6px; resize: none; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); - transform: none !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); } /* Hide resize handle on mobile */ @@ -1236,21 +1232,21 @@ html.mobile-init .file-browser-panel { /* Compact subagent window header */ .subagent-window-header { - padding: 0.3rem 0.5rem; - min-height: 32px; + padding: 0.2rem 0.4rem; + min-height: 24px; } .subagent-window-title { - gap: 0.25rem; + gap: 0.2rem; overflow: hidden; } .subagent-window-title .icon { - font-size: 0.8rem; + font-size: 0.7rem; } .subagent-window-title .id { - font-size: 0.65rem; + font-size: 0.6rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1258,51 +1254,50 @@ html.mobile-init .file-browser-panel { } .subagent-window-title .status { - font-size: 0.5rem; - padding: 0.05rem 0.2rem; + font-size: 0.45rem; + padding: 0.05rem 0.15rem; } .subagent-model-badge { - font-size: 0.5rem !important; - padding: 0.05rem 0.2rem !important; + font-size: 0.45rem !important; + padding: 0.05rem 0.15rem !important; } .subagent-window-actions button { - font-size: 0.8rem; - padding: 0.3rem 0.5rem; - min-width: 32px; - min-height: 32px; + font-size: 0.7rem; + padding: 0.15rem 0.35rem; + min-width: 26px; + min-height: 26px; display: flex; align-items: center; justify-content: center; } .subagent-window-body { - font-size: 0.65rem; - padding: 0.35rem; + font-size: 0.6rem; + padding: 0.2rem 0.35rem; } .subagent-window-body .activity-line { - gap: 0.2rem; - padding: 0.1rem 0; + gap: 0.15rem; + padding: 0.05rem 0; } .subagent-window-body .activity-line .time { - font-size: 0.55rem; + font-size: 0.5rem; } .subagent-window-body .activity-line .tool-name { - font-size: 0.6rem; + font-size: 0.55rem; } .subagent-window-body .activity-line .tool-detail { - font-size: 0.6rem; + font-size: 0.55rem; } - /* Parent label in subagent window - compact */ + /* Hide parent label on mobile to save space */ .subagent-window-parent { - padding: 0.15rem 0.5rem; - font-size: 0.6rem; + display: none; } /* ---- Tab Subagent Badges: Smaller on Mobile ---- */