diff --git a/CLAUDE.md b/CLAUDE.md index f88a8034..512dec6d 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.1460 (must match `package.json`) +**Version**: 0.1461 (must match `package.json` for npm publish) ## Project Overview @@ -58,12 +58,6 @@ systemctl --user restart claudeman-web journalctl --user -u claudeman-web -f ``` -## Binaries - -| Binary | Purpose | -|--------|---------| -| `claudeman` | Main CLI and web server | - ## Architecture ### Core Files @@ -73,17 +67,26 @@ journalctl --user -u claudeman-web -f | `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` | | `src/screen-manager.ts` | GNU screen persistence, ghost discovery | | `src/session-manager.ts` | Session lifecycle, cleanup | +| `src/state-store.ts` | State persistence to `~/.claudeman/state.json` | | `src/respawn-controller.ts` | State machine for autonomous cycling | | `src/ralph-tracker.ts` | Detects `PHRASE`, todos | +| `src/ralph-loop.ts` | Autonomous task execution loop (polls queue, assigns tasks) | +| `src/ralph-config.ts` | Parses `.claude/ralph-loop.local.md` plugin config | +| `src/task.ts` | Task model for prompt execution | +| `src/task-queue.ts` | Priority queue for tasks with dependencies | +| `src/task-tracker.ts` | Background task tracker for subagent detection | | `src/subagent-watcher.ts` | Monitors Claude Code's Task tool (background agents) | | `src/run-summary.ts` | Timeline events for "what happened while away" | | `src/ai-idle-checker.ts` | AI-powered idle detection with `ai-checker-base.ts` | +| `src/ai-plan-checker.ts` | AI-powered plan completion checker | | `src/bash-tool-parser.ts` | Parses Claude's bash tool invocations from output | | `src/transcript-watcher.ts` | Watches Claude's transcript files for changes | | `src/hooks-config.ts` | Manages `.claude/settings.local.json` hook configuration | | `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) | +| `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing | | `src/plan-orchestrator.ts` | Multi-agent plan generation with research and planning phases | | `src/prompts/*.ts` | Agent prompts (research-agent, code-reviewer, planner) | +| `src/cli.ts` | Command-line interface handlers | | `src/web/server.ts` | Fastify REST API + SSE at `/api/events` | | `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows | | `src/types.ts` | All TypeScript interfaces | @@ -275,3 +278,11 @@ When adding new event listeners or timers: **Frontend**: Store drag/resize handlers on elements, clean up in `close*()` functions. SSE reconnect calls `handleInit()` which resets state. Run `npx vitest run test/memory-leak-prevention.test.ts` to verify patterns. + +## Common Workflows + +**Investigating a bug**: Start dev server (`npx tsx src/index.ts web`), reproduce in browser, check terminal output and `~/.claudeman/state.json` for clues. + +**Adding a new API endpoint**: Define types in `types.ts`, add route in `server.ts:buildServer()`, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`. + +**Modifying respawn behavior**: Study `docs/respawn-state-machine.md` first. The state machine is in `respawn-controller.ts`. Use MockSession from `test/respawn-test-utils.ts` for testing. diff --git a/docs/screenshots/mobile-keyboard-visible.png b/docs/screenshots/mobile-keyboard-visible.png new file mode 100644 index 00000000..eeeb9e24 Binary files /dev/null and b/docs/screenshots/mobile-keyboard-visible.png differ diff --git a/docs/screenshots/mobile-main.png b/docs/screenshots/mobile-main.png new file mode 100644 index 00000000..89c27269 Binary files /dev/null and b/docs/screenshots/mobile-main.png differ diff --git a/docs/screenshots/mobile-no-keyboard.png b/docs/screenshots/mobile-no-keyboard.png new file mode 100644 index 00000000..b2172a90 Binary files /dev/null and b/docs/screenshots/mobile-no-keyboard.png differ diff --git a/docs/screenshots/mobile-session.png b/docs/screenshots/mobile-session.png new file mode 100644 index 00000000..cc2e2df1 Binary files /dev/null and b/docs/screenshots/mobile-session.png differ diff --git a/docs/screenshots/mobile-toolbar-above-keyboard.png b/docs/screenshots/mobile-toolbar-above-keyboard.png new file mode 100644 index 00000000..ec60fa9a Binary files /dev/null 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 new file mode 100644 index 00000000..71c04141 Binary files /dev/null and b/docs/screenshots/mobile-with-keyboard.png differ diff --git a/package.json b/package.json index d73cab96..aef5c22e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1460", + "version": "0.1461", "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 new file mode 100644 index 00000000..6e224776 --- /dev/null +++ b/scripts/mobile-screenshot.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * Take mobile screenshots of Claudeman using Playwright + * Emulates iPhone 15 Pro (closest to iPhone 17 Pro) + * Includes keyboard simulation + */ + +import { chromium, devices } from 'playwright'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { mkdirSync } from 'fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BASE_URL = 'http://localhost:3000'; +const OUTPUT_DIR = join(__dirname, '..', 'docs', 'screenshots'); + +// Ensure output directory exists +try { mkdirSync(OUTPUT_DIR, { recursive: true }); } catch {} + +// iPhone 15 Pro specs (closest to requested iPhone 17 Pro) +// Screen: 393 x 852 at 3x = 1179 x 2556 physical pixels +const VIEWPORT_WIDTH = 393; +const VIEWPORT_HEIGHT = 852; +const KEYBOARD_HEIGHT = 336; // Typical iOS keyboard height + +const iPhone15Pro = { + ...devices['iPhone 14 Pro'], + viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, + deviceScaleFactor: 3, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', +}; + +async function takeScreenshots() { + console.log('Launching browser with iPhone 15 Pro emulation...'); + console.log('Output directory:', OUTPUT_DIR); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + ...iPhone15Pro, + hasTouch: true, + isMobile: true, + }); + + const page = await context.newPage(); + + try { + // Screenshot 1: Main view (no keyboard) + console.log('Loading main page...'); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 10000 }); + await page.waitForTimeout(2000); + + await page.screenshot({ + path: join(OUTPUT_DIR, 'mobile-no-keyboard.png'), + fullPage: false + }); + console.log('āœ“ Saved: mobile-no-keyboard.png'); + + // 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 + await page.evaluate((keyboardHeight) => { + // Add keyboard-visible class + document.body.classList.add('keyboard-visible'); + + // Move toolbar up by keyboard height + const toolbar = document.querySelector('.toolbar'); + if (toolbar) { + toolbar.style.transform = `translateY(${-keyboardHeight}px)`; + } + }, KEYBOARD_HEIGHT); + + await page.waitForTimeout(300); + + // Take screenshot of the reduced viewport (simulating keyboard up) + await page.screenshot({ + path: join(OUTPUT_DIR, 'mobile-keyboard-visible.png'), + fullPage: false + }); + console.log('āœ“ Saved: mobile-keyboard-visible.png'); + + // Screenshot 3: Full view with keyboard overlay visualization + // Reset viewport but keep toolbar position to show the effect + await page.setViewportSize({ + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT + }); + + // Add a visual keyboard overlay + await page.evaluate((keyboardHeight) => { + const overlay = document.createElement('div'); + overlay.id = 'keyboard-overlay'; + overlay.style.cssText = ` + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: ${keyboardHeight}px; + background: linear-gradient(to bottom, #2a2a2a 0%, #1a1a1a 100%); + border-top: 1px solid #444; + z-index: 9999; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + padding: 8px 4px; + gap: 4px; + `; + + // Add fake keyboard keys + const rows = [ + 'Q W E R T Y U I O P', + 'A S D F G H J K L', + '⇧ Z X C V B N M ⌫', + '123 🌐 space return' + ]; + + rows.forEach((row, rowIndex) => { + const rowDiv = document.createElement('div'); + rowDiv.style.cssText = 'display: flex; width: 100%; justify-content: center; gap: 4px;'; + + row.split(' ').forEach(key => { + const keyEl = document.createElement('div'); + const isSpecial = ['⇧', '⌫', '123', '🌐', 'space', 'return'].includes(key); + const isSpace = key === 'space'; + const isReturn = key === 'return'; + + keyEl.style.cssText = ` + background: ${isSpecial ? '#3a3a3a' : '#505050'}; + color: #fff; + border-radius: 5px; + padding: ${isSpace ? '10px 60px' : isReturn ? '10px 20px' : '10px 8px'}; + font-size: ${rowIndex === 3 ? '12px' : '14px'}; + min-width: ${isSpace ? '120px' : isReturn ? '60px' : '28px'}; + text-align: center; + box-shadow: 0 1px 0 #222; + `; + keyEl.textContent = isSpace ? '' : key; + rowDiv.appendChild(keyEl); + }); + + overlay.appendChild(rowDiv); + }); + + document.body.appendChild(overlay); + }, KEYBOARD_HEIGHT); + + await page.waitForTimeout(200); + + await page.screenshot({ + path: join(OUTPUT_DIR, 'mobile-with-keyboard.png'), + fullPage: false + }); + console.log('āœ“ Saved: mobile-with-keyboard.png'); + + // Screenshot 4: Toolbar close-up with keyboard + const toolbar = page.locator('.toolbar'); + if (await toolbar.count() > 0) { + await toolbar.screenshot({ + path: join(OUTPUT_DIR, 'mobile-toolbar-above-keyboard.png') + }); + console.log('āœ“ Saved: mobile-toolbar-above-keyboard.png'); + } + + console.log('\nāœ… All screenshots saved to:', OUTPUT_DIR); + + } catch (error) { + console.error('Error taking screenshots:', error.message); + } finally { + await browser.close(); + } +} + +takeScreenshots(); diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 4236df3c..905a45a8 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -10,7 +10,7 @@ import { EventEmitter } from 'node:events'; import { watch, type FSWatcher } from 'chokidar'; -import { basename, extname } from 'node:path'; +import { basename, extname, relative } from 'node:path'; import { statSync } from 'node:fs'; import type { ImageDetectedEvent } from './types.js'; @@ -265,10 +265,14 @@ export class ImageWatcher extends EventEmitter { try { const stat = statSync(filePath); const fileName = basename(filePath); + const workingDir = this.sessionDirs.get(sessionId); + // Compute relative path from working directory (for file-raw endpoint) + const relativePath = workingDir ? relative(workingDir, filePath) : fileName; const event: ImageDetectedEvent = { sessionId, filePath, + relativePath, fileName, timestamp: Date.now(), size: stat.size, diff --git a/src/types.ts b/src/types.ts index 8ecf74b1..0618161a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1511,6 +1511,8 @@ export interface ImageDetectedEvent { sessionId: string; /** Full path to the detected image file */ filePath: string; + /** Path relative to the session's working directory (for file-raw endpoint) */ + relativePath: string; /** Image file name (basename) */ fileName: string; /** Timestamp when the image was detected */ diff --git a/src/web/public/app.js b/src/web/public/app.js index e4b47158..00d59bc8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -185,18 +185,20 @@ function getEventCoords(e) { /** * KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears. * Uses focusin event and scrollIntoView - keeps it simple and reliable. - * Also handles terminal scrolling via visualViewport API. + * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { lastViewportHeight: 0, keyboardVisible: false, + initialViewportHeight: 0, /** Initialize keyboard handling */ init() { // Only initialize on touch devices if (!MobileDetection.isTouchDevice()) return; - this.lastViewportHeight = window.visualViewport?.height || window.innerHeight; + this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; + this.lastViewportHeight = this.initialViewportHeight; // Simple focus handler - scroll input into view after keyboard appears document.addEventListener('focusin', (e) => { @@ -209,32 +211,68 @@ const KeyboardHandler = { }, 400); }); - // Use visualViewport to detect keyboard for terminal scrolling + // Use visualViewport to detect keyboard and reposition toolbar if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { this.handleViewportResize(); }); + // Also handle scroll (iOS scrolls viewport when keyboard appears) + window.visualViewport.addEventListener('scroll', () => { + this.updateToolbarPosition(); + }); } }, /** Handle viewport resize (keyboard show/hide) */ handleViewportResize() { const currentHeight = window.visualViewport?.height || window.innerHeight; - const heightDiff = this.lastViewportHeight - currentHeight; + const heightDiff = this.initialViewportHeight - currentHeight; // Keyboard appeared (viewport shrunk by more than 150px) if (heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); this.onKeyboardShow(); } - // Keyboard hidden (viewport grew back) - else if (heightDiff < -150 && this.keyboardVisible) { + // Keyboard hidden (viewport grew back close to initial) + else if (heightDiff < 50 && this.keyboardVisible) { this.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + this.onKeyboardHide(); } + this.updateToolbarPosition(); this.lastViewportHeight = currentHeight; }, + /** Update toolbar position based on visual viewport */ + updateToolbarPosition() { + if (!window.visualViewport) return; + + const toolbar = document.querySelector('.toolbar'); + if (!toolbar) return; + + // Only reposition on mobile + if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) { + toolbar.style.transform = ''; + return; + } + + 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: + const layoutHeight = window.innerHeight; + const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; + const offsetFromBottom = layoutHeight - visualBottom; + + // Move toolbar up by this offset (negative translateY moves up) + toolbar.style.transform = `translateY(${-offsetFromBottom}px)`; + } else { + toolbar.style.transform = ''; + } + }, + /** Called when keyboard appears */ onKeyboardShow() { // Scroll active terminal to bottom so input line is visible @@ -248,6 +286,15 @@ const KeyboardHandler = { }, 100); }, + /** Called when keyboard hides */ + onKeyboardHide() { + // Reset toolbar position + const toolbar = document.querySelector('.toolbar'); + if (toolbar) { + toolbar.style.transform = ''; + } + }, + /** Check if element is an input that triggers keyboard (excludes terminal) */ isInputElement(el) { if (!el) return false; @@ -12803,7 +12850,7 @@ class ClaudemanApp { * Called automatically when image:detected SSE event is received. */ openImagePopup(imageEvent) { - const { sessionId, filePath, fileName, timestamp, size } = imageEvent; + const { sessionId, filePath, relativePath, fileName, timestamp, size } = imageEvent; // Create unique window ID const imageId = `${sessionId}-${timestamp}`; @@ -12830,7 +12877,8 @@ class ClaudemanApp { const sizeKB = (size / 1024).toFixed(1); // Build image URL using the existing file-raw endpoint - const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(fileName)}`; + // Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images + const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`; // Create window element const win = document.createElement('div'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 3b5dba1b..d3532352 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -8056,7 +8056,7 @@ kbd { /* Clean dark toolbar - positioned near bottom */ .toolbar { - position: absolute; + position: fixed; bottom: var(--safe-area-bottom); left: 0; right: 0; @@ -8073,6 +8073,10 @@ kbd { background: #111111; border-top: 1px solid rgba(255, 255, 255, 0.1); z-index: 50; + /* Smooth transition for keyboard show/hide */ + transition: transform 0.15s ease-out; + /* Ensure transform works properly */ + will-change: transform; } /* Show case selector in center */ @@ -8438,6 +8442,18 @@ kbd { scroll-margin-bottom: 200px; scroll-margin-top: 80px; } + + /* When keyboard is visible, adjust content to account for moved toolbar */ + .keyboard-visible .terminal-container { + /* Reduce terminal height when keyboard is up so it doesn't overlap toolbar */ + padding-bottom: 50px; + } + + /* Ensure modals scroll properly when keyboard is visible */ + .keyboard-visible .modal-body { + max-height: 40vh; + overflow-y: auto; + } }