mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: UI improvements and E2E testing with agent-browser
- Move font controls (A+/A-) to left of connection status - Redesign tab count with stepper buttons (−/number/+) - Remove respawn panel, integrate settings into session options modal - Fix Monitor panel SSE handler (wrong element ID) - Add screen wrapping support to Session class - Add E2E testing skill using agent-browser - Document testing approach in README.md and CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# E2E Testing with Agent-Browser
|
||||
|
||||
This skill uses agent-browser to perform end-to-end testing of the Claudeman web interface.
|
||||
|
||||
## Usage
|
||||
|
||||
When this skill is invoked, run comprehensive E2E tests on the web interface.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Prerequisites
|
||||
1. Ensure the server is running: `npm run dev` or `claudeman web`
|
||||
2. Default URL: http://localhost:3000
|
||||
|
||||
### Test Execution
|
||||
|
||||
Use `npx agent-browser` to run the following tests:
|
||||
|
||||
```bash
|
||||
# Test 1: Connection and Initial Load
|
||||
npx agent-browser open http://localhost:3000
|
||||
npx agent-browser wait --load networkidle
|
||||
npx agent-browser snapshot
|
||||
npx agent-browser screenshot /tmp/claudeman-test-1-initial.png
|
||||
|
||||
# Test 2: Font Controls (A+/A-)
|
||||
npx agent-browser find text "A-" click
|
||||
npx agent-browser wait 500
|
||||
npx agent-browser find text "A+" click
|
||||
npx agent-browser find text "A+" click
|
||||
npx agent-browser screenshot /tmp/claudeman-test-2-font.png
|
||||
|
||||
# Test 3: Tab Count Stepper
|
||||
npx agent-browser find text "+" click # Increment tab count
|
||||
npx agent-browser find text "+" click
|
||||
npx agent-browser find text "−" click # Decrement
|
||||
npx agent-browser screenshot /tmp/claudeman-test-3-tabcount.png
|
||||
|
||||
# Test 4: Create Claude Session
|
||||
npx agent-browser find text "Run Claude" click
|
||||
npx agent-browser wait 2000
|
||||
npx agent-browser snapshot
|
||||
npx agent-browser screenshot /tmp/claudeman-test-4-session.png
|
||||
|
||||
# Test 5: Open Session Options (gear icon on tab)
|
||||
npx agent-browser snapshot # Get element refs
|
||||
# Find and click the gear icon on the session tab
|
||||
npx agent-browser screenshot /tmp/claudeman-test-5-options.png
|
||||
|
||||
# Test 6: Test Respawn Settings in Modal
|
||||
# Should see: Update Prompt, Idle Timeout, Step Delay, Duration, checkboxes
|
||||
npx agent-browser screenshot /tmp/claudeman-test-6-respawn.png
|
||||
|
||||
# Test 7: Monitor Panel
|
||||
npx agent-browser find text "Monitor" click
|
||||
npx agent-browser wait 500
|
||||
npx agent-browser snapshot
|
||||
npx agent-browser screenshot /tmp/claudeman-test-7-monitor.png
|
||||
|
||||
# Test 8: Close Monitor Panel
|
||||
npx agent-browser find text "×" click
|
||||
npx agent-browser screenshot /tmp/claudeman-test-8-closed.png
|
||||
|
||||
# Cleanup
|
||||
npx agent-browser close
|
||||
```
|
||||
|
||||
### Expected Results
|
||||
|
||||
1. **Initial Load**: Page loads with header containing: logo, session tabs, font controls (A-/14/A+), connection status, tokens display, settings gear
|
||||
2. **Font Controls**: Font controls should be to the LEFT of connection status
|
||||
3. **Tab Count Stepper**: Nice stepper with −/number/+ buttons, not just a plain number input
|
||||
4. **Session Creation**: Session tab appears, terminal shows Claude starting
|
||||
5. **Session Options**: Modal opens with session name, directory, AND respawn settings (Update Prompt, timeouts, checkboxes)
|
||||
6. **Monitor Panel**: Shows "Screen Sessions" and "Background Tasks" sections
|
||||
7. **Respawn Settings**: All settings integrated into the session options modal, NO separate bottom panel
|
||||
|
||||
### Verification Checklist
|
||||
|
||||
- [ ] Font controls positioned left of connection status
|
||||
- [ ] Tab count has nice stepper buttons (−/+)
|
||||
- [ ] No "Respawn Settings" button in footer
|
||||
- [ ] Respawn settings appear in Session Options modal
|
||||
- [ ] Monitor panel opens and shows both sections
|
||||
- [ ] Sessions are wrapped in GNU screen (check with `screen -ls`)
|
||||
@@ -322,6 +322,8 @@ GET /api/status # Full state snapshot (sessions + schedule
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Tests use Vitest and auto-discover `*.test.ts` files in the `test/` directory:
|
||||
|
||||
```bash
|
||||
@@ -332,6 +334,105 @@ npx vitest run test/session.test.ts # Single file
|
||||
npx vitest run -t "should create session" # By pattern
|
||||
```
|
||||
|
||||
### E2E Testing with agent-browser
|
||||
|
||||
For UI testing, we use [agent-browser](https://github.com/vercel-labs/agent-browser) - a fast CLI for browser automation optimized for AI agents.
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
npm install agent-browser --save-dev
|
||||
npx agent-browser install # Download Chromium
|
||||
```
|
||||
|
||||
**Basic E2E Test Flow:**
|
||||
```bash
|
||||
# Start the server
|
||||
npx tsx src/index.ts web &
|
||||
|
||||
# Open browser
|
||||
npx agent-browser open http://localhost:3000
|
||||
|
||||
# Get page snapshot (shows accessibility tree with element refs)
|
||||
npx agent-browser snapshot
|
||||
|
||||
# Click elements by reference
|
||||
npx agent-browser click @e5 # Click element with ref=e5
|
||||
|
||||
# Or use semantic locators
|
||||
npx agent-browser find text "Run Claude" click
|
||||
npx agent-browser find role button click --name "Monitor"
|
||||
|
||||
# Fill inputs
|
||||
npx agent-browser fill @e32 "my-session-name"
|
||||
|
||||
# Execute JavaScript in page context
|
||||
npx agent-browser eval "app.sessions.get(app.activeSessionId)"
|
||||
|
||||
# Take screenshots
|
||||
npx agent-browser screenshot /tmp/test-result.png
|
||||
|
||||
# Close browser
|
||||
npx agent-browser close
|
||||
```
|
||||
|
||||
**E2E Test Skill (`.claude/skills/e2e-test.md`):**
|
||||
|
||||
A skill file exists that documents the full E2E test plan. Key tests include:
|
||||
|
||||
1. **Initial Load**: Verify header layout (font controls, connection status, tokens)
|
||||
2. **Font Controls**: Test A-/A+ buttons change font size
|
||||
3. **Tab Count Stepper**: Test −/+ buttons increment/decrement
|
||||
4. **Session Creation**: Create session, verify screen wrapping
|
||||
5. **Session Options Modal**: Open gear icon, verify respawn settings visible
|
||||
6. **Monitor Panel**: Test Screen Sessions and Background Tasks display
|
||||
|
||||
**Example Test Session:**
|
||||
```bash
|
||||
# Clean up previous screens
|
||||
screen -ls | grep -oP '\d+\.claudeman-[a-z0-9]+' | while read s; do
|
||||
screen -S "$s" -X quit
|
||||
done
|
||||
|
||||
# Start fresh server
|
||||
rm -f ~/.claudeman/screens.json
|
||||
npx tsx src/index.ts web &
|
||||
sleep 4
|
||||
|
||||
# Run tests
|
||||
npx agent-browser open http://localhost:3000
|
||||
npx agent-browser snapshot | head -30 # Check initial state
|
||||
npx agent-browser click @e5 # Run Claude
|
||||
sleep 4
|
||||
npx agent-browser snapshot # Verify session created
|
||||
|
||||
# Check screen wrapping
|
||||
screen -ls | grep claudeman # Should show screen session
|
||||
|
||||
# Check session has PID
|
||||
npx agent-browser eval "app.sessions.get(app.activeSessionId).pid"
|
||||
|
||||
# Test session options
|
||||
npx agent-browser eval "document.querySelector('[title=\"Session options\"]').click()"
|
||||
npx agent-browser snapshot | grep -E "Respawn|Enable" # Should show respawn settings
|
||||
|
||||
npx agent-browser screenshot /tmp/test-final.png
|
||||
npx agent-browser close
|
||||
```
|
||||
|
||||
**Key Commands:**
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `open <url>` | Navigate to URL |
|
||||
| `snapshot` | Get accessibility tree with element refs |
|
||||
| `snapshot -i` | Interactive elements only |
|
||||
| `click @ref` | Click element by ref |
|
||||
| `fill @ref "text"` | Fill input field |
|
||||
| `eval "js code"` | Execute JavaScript |
|
||||
| `screenshot path` | Save screenshot |
|
||||
| `find text/role/label "x" click` | Semantic element location |
|
||||
| `wait 1000` | Wait milliseconds |
|
||||
| `close` | Close browser |
|
||||
|
||||
## Frontend
|
||||
|
||||
The web UI (`src/web/public/`) uses vanilla JavaScript with:
|
||||
|
||||
@@ -386,6 +386,91 @@ npm run build
|
||||
npm run clean
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
npm run test # Run all tests once
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # With coverage report
|
||||
npx vitest run test/session.test.ts # Single file
|
||||
npx vitest run -t "should create session" # By pattern
|
||||
```
|
||||
|
||||
### E2E Testing with agent-browser
|
||||
|
||||
The project uses [agent-browser](https://github.com/vercel-labs/agent-browser) for end-to-end testing of the web interface. This tool provides browser automation via accessibility tree snapshots and element references.
|
||||
|
||||
#### Installation
|
||||
|
||||
```bash
|
||||
npm install -g agent-browser
|
||||
```
|
||||
|
||||
#### Running E2E Tests
|
||||
|
||||
1. Start the web server:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
claudeman web
|
||||
```
|
||||
|
||||
2. Kill any existing screen sessions (clean slate):
|
||||
```bash
|
||||
screen -ls | grep -oP '\d+\.\S+' | xargs -I{} screen -X -S {} quit 2>/dev/null || true
|
||||
```
|
||||
|
||||
3. Run tests with agent-browser:
|
||||
```bash
|
||||
# Open the app and take initial snapshot
|
||||
npx agent-browser open http://localhost:3000
|
||||
npx agent-browser wait --load networkidle
|
||||
npx agent-browser snapshot
|
||||
npx agent-browser screenshot /tmp/claudeman-test-initial.png
|
||||
|
||||
# Test font controls
|
||||
npx agent-browser find text "A-" click
|
||||
npx agent-browser find text "A+" click
|
||||
|
||||
# Test tab count stepper
|
||||
npx agent-browser find text "+" click # Increment
|
||||
npx agent-browser find text "−" click # Decrement
|
||||
|
||||
# Test session creation
|
||||
npx agent-browser find text "Run Claude" click
|
||||
npx agent-browser wait 2000
|
||||
npx agent-browser snapshot
|
||||
|
||||
# Test Monitor panel
|
||||
npx agent-browser find text "Monitor" click
|
||||
npx agent-browser wait 500
|
||||
npx agent-browser snapshot
|
||||
|
||||
# Cleanup
|
||||
npx agent-browser close
|
||||
```
|
||||
|
||||
#### E2E Test Skill
|
||||
|
||||
A skill is available at `.claude/skills/e2e-test.md` that documents the complete test plan with all test cases and expected results. Run it with Claude Code:
|
||||
|
||||
```bash
|
||||
/e2e-test
|
||||
```
|
||||
|
||||
#### Key Test Areas
|
||||
|
||||
| Area | What to Test |
|
||||
|------|--------------|
|
||||
| Initial Load | Header, tabs, font controls, connection status |
|
||||
| Font Controls | A-/A+ buttons positioned left of connection |
|
||||
| Tab Count | Stepper with −/number/+ buttons |
|
||||
| Session Creation | Tab appears, terminal shows Claude starting |
|
||||
| Session Options | Modal with respawn settings |
|
||||
| Monitor Panel | Screen sessions and background tasks display |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
|
||||
Generated
+62
@@ -23,6 +23,7 @@
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"agent-browser": "^0.6.0",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^4.0.17"
|
||||
@@ -1335,6 +1336,22 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-browser": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.6.0.tgz",
|
||||
"integrity": "sha512-C4Mtxfvyi/m04hyD2XdUNKc878RPZqbhXFIPEUJElZW6RhZJ1QG1/L5PSgIAhRSi4TF/EkB8iq4IAWoecMvGdg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.57.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"bin": {
|
||||
"agent-browser": "bin/agent-browser"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -2267,6 +2284,19 @@
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -3107,6 +3137,38 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"agent-browser": "^0.6.0",
|
||||
"tsx": "^4.15.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^4.0.17"
|
||||
|
||||
+101
-20
@@ -1,8 +1,10 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as pty from 'node-pty';
|
||||
import { SessionState, SessionStatus, SessionConfig } from './types.js';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SessionState, SessionStatus, SessionConfig, ScreenSession } from './types.js';
|
||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { ScreenManager } from './screen-manager.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
|
||||
@@ -89,7 +91,18 @@ export class Session extends EventEmitter {
|
||||
private _autoClearEnabled: boolean = false;
|
||||
private _isClearing: boolean = false; // Prevent recursive clearing
|
||||
|
||||
constructor(config: Partial<SessionConfig> & { workingDir: string; mode?: SessionMode; name?: string }) {
|
||||
// Screen session support
|
||||
private _screenManager: ScreenManager | null = null;
|
||||
private _screenSession: ScreenSession | null = null;
|
||||
private _useScreen: boolean = false;
|
||||
|
||||
constructor(config: Partial<SessionConfig> & {
|
||||
workingDir: string;
|
||||
mode?: SessionMode;
|
||||
name?: string;
|
||||
screenManager?: ScreenManager;
|
||||
useScreen?: boolean;
|
||||
}) {
|
||||
super();
|
||||
this.id = config.id || uuidv4();
|
||||
this.workingDir = config.workingDir;
|
||||
@@ -97,6 +110,8 @@ export class Session extends EventEmitter {
|
||||
this.mode = config.mode || 'claude';
|
||||
this._name = config.name || '';
|
||||
this._lastActivityAt = this.createdAt;
|
||||
this._screenManager = config.screenManager || null;
|
||||
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
|
||||
|
||||
// Initialize task tracker and forward events
|
||||
this._taskTracker = new TaskTracker();
|
||||
@@ -286,17 +301,46 @@ export class Session extends EventEmitter {
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
console.log('[Session] Starting interactive Claude session');
|
||||
console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : ''));
|
||||
|
||||
this.ptyProcess = pty.spawn('claude', [
|
||||
'--dangerously-skip-permissions'
|
||||
], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
// If screen wrapping is enabled, create a screen session first
|
||||
if (this._useScreen && this._screenManager) {
|
||||
try {
|
||||
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude');
|
||||
console.log('[Session] Created screen session:', this._screenSession.screenName);
|
||||
|
||||
// Wait a moment for screen to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Attach to the screen session via PTY
|
||||
this.ptyProcess = pty.spawn('screen', [
|
||||
'-x', this._screenSession.screenName
|
||||
], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||
this._useScreen = false;
|
||||
this._screenSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct PTY if screen is not used
|
||||
if (!this.ptyProcess) {
|
||||
this.ptyProcess = pty.spawn('claude', [
|
||||
'--dangerously-skip-permissions'
|
||||
], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
}
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -350,6 +394,10 @@ export class Session extends EventEmitter {
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
this._status = 'idle';
|
||||
// If using screen, mark the screen as detached but don't kill it
|
||||
if (this._screenSession && this._screenManager) {
|
||||
this._screenManager.setAttached(this.id, false);
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
}
|
||||
@@ -371,15 +419,44 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
console.log('[Session] Starting shell session with:', shell);
|
||||
console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : ''));
|
||||
|
||||
this.ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
// If screen wrapping is enabled, create a screen session first
|
||||
if (this._useScreen && this._screenManager) {
|
||||
try {
|
||||
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell');
|
||||
console.log('[Session] Created screen session:', this._screenSession.screenName);
|
||||
|
||||
// Wait a moment for screen to fully start
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Attach to the screen session via PTY
|
||||
this.ptyProcess = pty.spawn('screen', [
|
||||
'-x', this._screenSession.screenName
|
||||
], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||
this._useScreen = false;
|
||||
this._screenSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct PTY if screen is not used
|
||||
if (!this.ptyProcess) {
|
||||
this.ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: { ...process.env, TERM: 'xterm-256color' },
|
||||
});
|
||||
}
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Shell PTY spawned with PID:', this._pid);
|
||||
@@ -406,6 +483,10 @@ export class Session extends EventEmitter {
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
this._status = 'idle';
|
||||
// If using screen, mark the screen as detached but don't kill it
|
||||
if (this._screenSession && this._screenManager) {
|
||||
this._screenManager.setAttached(this.id, false);
|
||||
}
|
||||
this.emit('exit', exitCode);
|
||||
});
|
||||
|
||||
|
||||
+94
-153
@@ -209,12 +209,13 @@ class ClaudemanApp {
|
||||
|
||||
this.eventSource.addEventListener('session:updated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.sessions.set(data.id, data);
|
||||
const session = data.session || data;
|
||||
this.sessions.set(session.id, session);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
// Update tokens display if this is the active session
|
||||
if (data.id === this.activeSessionId && data.tokens) {
|
||||
this.updateRespawnTokens(data.tokens.total);
|
||||
if (session.id === this.activeSessionId && session.tokens) {
|
||||
this.updateRespawnTokens(session.tokens.total);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -423,7 +424,7 @@ class ClaudemanApp {
|
||||
|
||||
this.eventSource.addEventListener('screen:statsUpdated', (e) => {
|
||||
this.screenSessions = JSON.parse(e.data);
|
||||
if (document.getElementById('processPanel').classList.contains('open')) {
|
||||
if (document.getElementById('monitorPanel').classList.contains('open')) {
|
||||
this.renderScreenSessions();
|
||||
}
|
||||
});
|
||||
@@ -676,6 +677,19 @@ class ClaudemanApp {
|
||||
return this.runClaude();
|
||||
}
|
||||
|
||||
// Tab count stepper functions
|
||||
incrementTabCount() {
|
||||
const input = document.getElementById('tabCount');
|
||||
const current = parseInt(input.value) || 1;
|
||||
input.value = Math.min(10, current + 1);
|
||||
}
|
||||
|
||||
decrementTabCount() {
|
||||
const input = document.getElementById('tabCount');
|
||||
const current = parseInt(input.value) || 1;
|
||||
input.value = Math.max(1, current - 1);
|
||||
}
|
||||
|
||||
async runClaude() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const tabCount = Math.min(10, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
|
||||
@@ -824,145 +838,7 @@ class ClaudemanApp {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ========== Respawn Panel ==========
|
||||
|
||||
toggleRespawnPanel() {
|
||||
const panel = document.getElementById('respawnPanel');
|
||||
panel.classList.toggle('open');
|
||||
|
||||
// Show "Enable on Current" button if there's an active session
|
||||
const enableOnCurrentBtn = document.getElementById('enableOnCurrentBtn');
|
||||
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
|
||||
const session = this.sessions.get(this.activeSessionId);
|
||||
// Only show if session is running (has a PID)
|
||||
if (session.pid) {
|
||||
enableOnCurrentBtn.style.display = '';
|
||||
} else {
|
||||
enableOnCurrentBtn.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
enableOnCurrentBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
getRespawnConfig() {
|
||||
const updatePrompt = document.getElementById('respawnPrompt').value;
|
||||
const idleTimeout = parseInt(document.getElementById('respawnIdleTimeout').value) || 5;
|
||||
const stepDelay = parseInt(document.getElementById('respawnStepDelay').value) || 1;
|
||||
const sendClear = document.getElementById('respawnSendClear').checked;
|
||||
const sendInit = document.getElementById('respawnSendInit').checked;
|
||||
const durationStr = document.getElementById('respawnDuration').value;
|
||||
const durationMinutes = durationStr ? parseInt(durationStr) : null;
|
||||
const autoClearEnabled = document.getElementById('autoClearEnabled').checked;
|
||||
const autoClearThreshold = parseInt(document.getElementById('autoClearThreshold').value) || 100000;
|
||||
|
||||
return {
|
||||
respawnConfig: {
|
||||
updatePrompt,
|
||||
idleTimeoutMs: idleTimeout * 1000,
|
||||
interStepDelayMs: stepDelay * 1000,
|
||||
sendClear,
|
||||
sendInit,
|
||||
},
|
||||
durationMinutes,
|
||||
autoClearEnabled,
|
||||
autoClearThreshold
|
||||
};
|
||||
}
|
||||
|
||||
async startInteractiveWithRespawn() {
|
||||
this.toggleRespawnPanel();
|
||||
|
||||
const dir = document.getElementById('dirInput').value.trim();
|
||||
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig();
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln('\x1b[1;32m Starting session with respawn...\x1b[0m');
|
||||
if (durationMinutes) {
|
||||
this.terminal.writeln(`\x1b[90m Duration: ${durationMinutes} minutes\x1b[0m`);
|
||||
}
|
||||
this.terminal.writeln('');
|
||||
|
||||
try {
|
||||
// Create session
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: dir || undefined })
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
|
||||
const sessionId = createData.session.id;
|
||||
this.activeSessionId = sessionId;
|
||||
|
||||
// Start interactive with respawn
|
||||
await fetch(`/api/sessions/${sessionId}/interactive-respawn`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ respawnConfig, durationMinutes })
|
||||
});
|
||||
|
||||
// Set auto-clear if enabled
|
||||
if (autoClearEnabled) {
|
||||
await fetch(`/api/sessions/${sessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
|
||||
});
|
||||
}
|
||||
|
||||
// Send resize
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||||
});
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
async enableRespawnOnCurrent() {
|
||||
if (!this.activeSessionId) {
|
||||
this.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleRespawnPanel();
|
||||
|
||||
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig();
|
||||
|
||||
try {
|
||||
// Enable respawn on existing session
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/respawn/enable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: respawnConfig, durationMinutes })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
// Set auto-clear if enabled
|
||||
if (autoClearEnabled) {
|
||||
await fetch(`/api/sessions/${this.activeSessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
|
||||
});
|
||||
}
|
||||
|
||||
this.showToast('Respawn enabled on current session', 'success');
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this.showToast('Failed to enable respawn: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
// ========== Respawn Banner ==========
|
||||
|
||||
showRespawnBanner() {
|
||||
document.getElementById('respawnBanner').style.display = 'flex';
|
||||
@@ -1201,22 +1077,22 @@ class ClaudemanApp {
|
||||
document.getElementById('sessionNameInput').value = session.name || '';
|
||||
document.getElementById('sessionDirDisplay').textContent = session.workingDir || 'Unknown';
|
||||
|
||||
// Update respawn status display
|
||||
// Update respawn status display and buttons
|
||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||
const stopBtn = document.getElementById('sessionStopRespawnBtn');
|
||||
const configBtn = document.getElementById('sessionConfigRespawnBtn');
|
||||
const enableBtn = document.getElementById('modalEnableRespawnBtn');
|
||||
const stopBtn = document.getElementById('modalStopRespawnBtn');
|
||||
|
||||
if (this.respawnStatus[sessionId]) {
|
||||
respawnStatus.classList.add('active');
|
||||
respawnStatus.querySelector('.respawn-status-text').textContent =
|
||||
`Active (${this.respawnStatus[sessionId].state || 'running'})`;
|
||||
this.respawnStatus[sessionId].state || 'Active';
|
||||
enableBtn.style.display = 'none';
|
||||
stopBtn.style.display = '';
|
||||
configBtn.textContent = 'Reconfigure';
|
||||
} else {
|
||||
respawnStatus.classList.remove('active');
|
||||
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
|
||||
enableBtn.style.display = '';
|
||||
stopBtn.style.display = 'none';
|
||||
configBtn.textContent = 'Configure Respawn';
|
||||
}
|
||||
|
||||
// Only show respawn section for claude mode sessions with a running process
|
||||
@@ -1233,7 +1109,73 @@ class ClaudemanApp {
|
||||
setTimeout(() => document.getElementById('sessionNameInput').focus(), 100);
|
||||
}
|
||||
|
||||
async stopRespawnFromOptions() {
|
||||
// Get respawn config from modal inputs
|
||||
getModalRespawnConfig() {
|
||||
const updatePrompt = document.getElementById('modalRespawnPrompt').value;
|
||||
const idleTimeout = parseInt(document.getElementById('modalRespawnIdleTimeout').value) || 5;
|
||||
const stepDelay = parseInt(document.getElementById('modalRespawnStepDelay').value) || 1;
|
||||
const sendClear = document.getElementById('modalRespawnSendClear').checked;
|
||||
const sendInit = document.getElementById('modalRespawnSendInit').checked;
|
||||
const durationStr = document.getElementById('modalRespawnDuration').value;
|
||||
const durationMinutes = durationStr ? parseInt(durationStr) : null;
|
||||
const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked;
|
||||
const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 100000;
|
||||
|
||||
return {
|
||||
respawnConfig: {
|
||||
updatePrompt,
|
||||
idleTimeoutMs: idleTimeout * 1000,
|
||||
interStepDelayMs: stepDelay * 1000,
|
||||
sendClear,
|
||||
sendInit,
|
||||
},
|
||||
durationMinutes,
|
||||
autoClearEnabled,
|
||||
autoClearThreshold
|
||||
};
|
||||
}
|
||||
|
||||
async enableRespawnFromModal() {
|
||||
if (!this.editingSessionId) {
|
||||
this.showToast('No session selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getModalRespawnConfig();
|
||||
|
||||
try {
|
||||
// Enable respawn on the session
|
||||
const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: respawnConfig, durationMinutes })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
// Set auto-clear if enabled
|
||||
if (autoClearEnabled) {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
|
||||
});
|
||||
}
|
||||
|
||||
// Update UI
|
||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||
respawnStatus.classList.add('active');
|
||||
respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING';
|
||||
document.getElementById('modalEnableRespawnBtn').style.display = 'none';
|
||||
document.getElementById('modalStopRespawnBtn').style.display = '';
|
||||
|
||||
this.showToast('Respawn enabled', 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to enable respawn: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async stopRespawnFromModal() {
|
||||
if (!this.editingSessionId) return;
|
||||
try {
|
||||
await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' });
|
||||
@@ -1243,8 +1185,8 @@ class ClaudemanApp {
|
||||
const respawnStatus = document.getElementById('sessionRespawnStatus');
|
||||
respawnStatus.classList.remove('active');
|
||||
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
|
||||
document.getElementById('sessionStopRespawnBtn').style.display = 'none';
|
||||
document.getElementById('sessionConfigRespawnBtn').textContent = 'Configure Respawn';
|
||||
document.getElementById('modalEnableRespawnBtn').style.display = '';
|
||||
document.getElementById('modalStopRespawnBtn').style.display = 'none';
|
||||
|
||||
this.showToast('Respawn stopped', 'success');
|
||||
} catch (err) {
|
||||
@@ -1412,7 +1354,6 @@ class ClaudemanApp {
|
||||
this.closeSessionOptions();
|
||||
this.closeAppSettings();
|
||||
this.cancelCloseSession();
|
||||
document.getElementById('respawnPanel').classList.remove('open');
|
||||
document.getElementById('monitorPanel').classList.remove('open');
|
||||
}
|
||||
|
||||
|
||||
+85
-88
@@ -25,16 +25,16 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="connection-status" id="connectionStatus">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Connecting</span>
|
||||
</div>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)">A-</button>
|
||||
<span class="font-size-display" id="fontSizeDisplay">14</span>
|
||||
<button class="btn-icon-header btn-sm" onclick="app.increaseFontSize()" title="Increase font (Ctrl++)">A+</button>
|
||||
</div>
|
||||
<div class="connection-status" id="connectionStatus">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">Connecting</span>
|
||||
</div>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -73,7 +73,11 @@
|
||||
<button class="btn-toolbar btn-primary" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
|
||||
Run Claude
|
||||
</button>
|
||||
<input type="number" id="tabCount" class="toolbar-input-sm" value="1" min="1" max="10" title="Number of tabs to open (1-10)">
|
||||
<div class="tab-count-group" title="Number of tabs to open (1-10)">
|
||||
<button class="tab-count-btn" onclick="app.decrementTabCount()">−</button>
|
||||
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="10" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
|
||||
</div>
|
||||
<button class="btn-toolbar" onclick="app.runShell()" title="Run Shell">
|
||||
Shell
|
||||
</button>
|
||||
@@ -102,78 +106,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Respawn Settings Panel (slide-up) -->
|
||||
<div class="respawn-panel" id="respawnPanel">
|
||||
<div class="respawn-panel-header">
|
||||
<span>Respawn Settings</span>
|
||||
<button class="btn-close" onclick="app.toggleRespawnPanel()">×</button>
|
||||
</div>
|
||||
<div class="respawn-panel-body">
|
||||
<div class="form-row">
|
||||
<label>Update Prompt</label>
|
||||
<input type="text" id="respawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
|
||||
</div>
|
||||
<div class="form-row-inline">
|
||||
<div class="form-col">
|
||||
<label>Idle Timeout</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="respawnIdleTimeout" value="5" min="1" max="60">
|
||||
<span>sec</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label>Step Delay</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="respawnStepDelay" value="1" min="0" max="10">
|
||||
<span>sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Run Duration (optional)</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="respawnDuration" value="" min="1" placeholder="No limit">
|
||||
<span>min</span>
|
||||
</div>
|
||||
<span class="form-hint">Leave empty for unlimited. Respawn stops after duration.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>After Update Prompt:</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="respawnSendClear" checked>
|
||||
<span>Send /clear</span>
|
||||
<span class="checkbox-hint">Clear conversation context</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="respawnSendInit" checked>
|
||||
<span>Send /init</span>
|
||||
<span class="checkbox-hint">Reinitialize session</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Auto-Clear Context</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="autoClearEnabled">
|
||||
<span>Enable Auto-Clear</span>
|
||||
<span class="checkbox-hint">Clear at token threshold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-suffix" style="margin-top: 0.5rem;">
|
||||
<input type="number" id="autoClearThreshold" value="100000" min="10000" max="500000" step="10000">
|
||||
<span>tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.toggleRespawnPanel()">Cancel</button>
|
||||
<button class="btn-toolbar btn-success" onclick="app.enableRespawnOnCurrent()" id="enableOnCurrentBtn" style="display: none;">Enable on Current</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.startInteractiveWithRespawn()">Start New + Respawn</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<div class="modal" id="helpModal">
|
||||
<div class="modal-backdrop" onclick="app.closeHelp()"></div>
|
||||
@@ -228,10 +160,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal -->
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Session Options</h3>
|
||||
<button class="modal-close" onclick="app.closeSessionOptions()">×</button>
|
||||
@@ -245,20 +177,85 @@
|
||||
<label>Directory</label>
|
||||
<div class="session-dir-display" id="sessionDirDisplay">/path/to/dir</div>
|
||||
</div>
|
||||
<div class="form-row session-respawn-section" id="sessionRespawnSection">
|
||||
<label>Respawn Controller</label>
|
||||
<div class="session-respawn-status" id="sessionRespawnStatus">
|
||||
<span class="respawn-status-text">Not active</span>
|
||||
|
||||
<!-- Respawn Settings Section -->
|
||||
<div class="session-respawn-section" id="sessionRespawnSection">
|
||||
<div class="section-divider">
|
||||
<span>Respawn Controller</span>
|
||||
<div class="session-respawn-status" id="sessionRespawnStatus">
|
||||
<span class="respawn-status-indicator"></span>
|
||||
<span class="respawn-status-text">Not active</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-respawn-actions">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.toggleRespawnPanel(); app.closeSessionOptions();" id="sessionConfigRespawnBtn">Configure Respawn</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.stopRespawnFromOptions()" id="sessionStopRespawnBtn" style="display: none;">Stop Respawn</button>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Update Prompt</label>
|
||||
<input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
|
||||
</div>
|
||||
|
||||
<div class="form-row-inline">
|
||||
<div class="form-col">
|
||||
<label>Idle Timeout</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="modalRespawnIdleTimeout" value="5" min="1" max="60">
|
||||
<span>sec</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-col">
|
||||
<label>Step Delay</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="modalRespawnStepDelay" value="1" min="0" max="10">
|
||||
<span>sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Run Duration</label>
|
||||
<div class="input-suffix">
|
||||
<input type="number" id="modalRespawnDuration" value="" min="1" placeholder="No limit">
|
||||
<span>min</span>
|
||||
</div>
|
||||
<span class="form-hint">Leave empty for unlimited. Respawn stops after duration.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>After Update Prompt</label>
|
||||
<div class="checkbox-group-inline">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalRespawnSendClear" checked>
|
||||
<span>Send /clear</span>
|
||||
</label>
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalRespawnSendInit" checked>
|
||||
<span>Send /init</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Auto-Clear Context</label>
|
||||
<div class="auto-clear-row">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="modalAutoClearEnabled">
|
||||
<span>Enable at</span>
|
||||
</label>
|
||||
<div class="input-suffix input-suffix-sm">
|
||||
<input type="number" id="modalAutoClearThreshold" value="100000" min="10000" max="500000" step="10000">
|
||||
<span>tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="respawn-actions">
|
||||
<button class="btn-toolbar btn-success" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable Respawn</button>
|
||||
<button class="btn-toolbar btn-danger" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop Respawn</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn-toolbar" onclick="app.closeSessionOptions()">Cancel</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveSessionOptions()">Save</button>
|
||||
<button class="btn-toolbar btn-primary" onclick="app.saveSessionOptions()">Save Name</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+141
-47
@@ -539,6 +539,66 @@ body {
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
/* Tab Count Input Group - nicer stepper design */
|
||||
.tab-count-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-count-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-count-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tab-count-btn:active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-count-input {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.tab-count-input::-webkit-inner-spin-button,
|
||||
.tab-count-input::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tab-count-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-input-sm {
|
||||
padding: 0.3rem 0.4rem;
|
||||
background: var(--bg-input);
|
||||
@@ -586,39 +646,7 @@ body {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
/* Slide-up Panels */
|
||||
.respawn-panel,
|
||||
.new-session-panel {
|
||||
position: fixed;
|
||||
bottom: var(--toolbar-height);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100%);
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.respawn-panel.open,
|
||||
.new-session-panel.open {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.respawn-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close button for modals */
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -633,10 +661,6 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.respawn-panel-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
@@ -828,33 +852,104 @@ body {
|
||||
.session-respawn-section {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-respawn-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-respawn-status .respawn-status-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-respawn-status .respawn-status-text {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.session-respawn-status.active {
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
.session-respawn-status.active .respawn-status-indicator {
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.session-respawn-status.active .respawn-status-text {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Inline checkbox group */
|
||||
.checkbox-group-inline {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.checkbox-inline input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox-inline:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Auto-clear row */
|
||||
.auto-clear-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.input-suffix-sm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-suffix-sm input {
|
||||
width: 80px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.input-suffix-sm span {
|
||||
padding: 0.35rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Respawn actions in modal */
|
||||
.respawn-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.session-respawn-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -1385,8 +1480,7 @@ kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.respawn-panel,
|
||||
.new-session-panel {
|
||||
.monitor-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
+15
-3
@@ -118,7 +118,9 @@ export class WebServer extends EventEmitter {
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: body.mode || 'claude',
|
||||
name: body.name || ''
|
||||
name: body.name || '',
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -256,6 +258,7 @@ export class WebServer extends EventEmitter {
|
||||
try {
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id });
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
return { success: true, message: 'Interactive session started' };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
@@ -278,6 +281,7 @@ export class WebServer extends EventEmitter {
|
||||
try {
|
||||
await session.startShell();
|
||||
this.broadcast('session:interactive', { id, mode: 'shell' });
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
return { success: true, message: 'Shell session started' };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
@@ -419,6 +423,7 @@ export class WebServer extends EventEmitter {
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id });
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
|
||||
// Create and start respawn controller
|
||||
const controller = new RespawnController(session, body?.respawnConfig);
|
||||
@@ -651,7 +656,11 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
const session = new Session({ workingDir: casePath });
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true
|
||||
});
|
||||
this.sessions.set(session.id, session);
|
||||
this.setupSessionListeners(session);
|
||||
this.broadcast('session:created', session.toDetailedState());
|
||||
@@ -660,6 +669,7 @@ export class WebServer extends EventEmitter {
|
||||
try {
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id: session.id });
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -1053,7 +1063,9 @@ export class WebServer extends EventEmitter {
|
||||
id: screen.sessionId, // Preserve the original session ID
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
name: `Restored: ${screen.screenName}`
|
||||
name: `Restored: ${screen.screenName}`,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
|
||||
Reference in New Issue
Block a user