mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: parallel session creation and improved minimized agents UI
- Session creation now parallelized (3x faster for multiple tabs) - Minimized agents badge: compact, hover-triggered, auto-hide - Removed "Minimized Agents" header, status dots instead of text - Click to pin dropdown open, hover for quick access Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## ⚡ COM Shorthand (Deployment)
|
||||
|
||||
When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md`, 2) `git add && git commit && git push && npm run build && systemctl --user restart claudeman-web`. Always bump version on every COM.
|
||||
When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md` (keep them in sync), 2) `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`. Always bump version on every COM, even for small changes.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||
|
||||
**Version**: 0.1384 (must match `package.json`)
|
||||
**Version**: 0.1385 (must match `package.json`)
|
||||
|
||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||
|
||||
@@ -43,6 +43,12 @@ npm install
|
||||
|
||||
**CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development.
|
||||
|
||||
**Quick reference**:
|
||||
- Dev server: `npx tsx src/index.ts web` (or `web --https` for notifications)
|
||||
- Type check: `npx tsc --noEmit`
|
||||
- Single test: `npx vitest run test/<file>.test.ts`
|
||||
- Restart prod: `systemctl --user restart claudeman-web`
|
||||
|
||||
### Build & Clean
|
||||
|
||||
```bash
|
||||
@@ -523,7 +529,11 @@ Use `createErrorResponse(code, details?)` from `types.ts`:
|
||||
|
||||
## TUI (WIP)
|
||||
|
||||
Ink/React-based TUI in `src/tui/`. Client to the web server, uses `/api/*` endpoints and attaches to screens via GNU screen. Not fully implemented yet.
|
||||
Ink/React-based TUI in `src/tui/`. Client to the web server, uses `/api/*` endpoints and attaches to screens via GNU screen.
|
||||
|
||||
**Key files**: `App.tsx` (main component), `DirectAttach.ts` (full-screen attach with tab switching), `SessionList.tsx`, `SessionView.tsx`.
|
||||
|
||||
**Current state**: Basic session list and direct attach work. Missing: full session management UI, settings panel, notifications.
|
||||
|
||||
## Buffer Limits
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1384",
|
||||
"version": "0.1385",
|
||||
"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",
|
||||
|
||||
+22
-3
@@ -16,7 +16,7 @@
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js';
|
||||
@@ -56,6 +56,7 @@ const DEFAULT_STATS_INTERVAL_MS = 2000;
|
||||
/** Maximum retry attempts for carriage return (3) */
|
||||
const CR_MAX_ATTEMPTS = 3;
|
||||
|
||||
|
||||
/** Cached directory containing the claude binary */
|
||||
let _claudeDir: string | null = null;
|
||||
|
||||
@@ -193,7 +194,10 @@ export class ScreenManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Save screens to disk
|
||||
/**
|
||||
* Save screens to disk asynchronously.
|
||||
* Uses async write to avoid blocking the event loop.
|
||||
*/
|
||||
private saveScreens(): void {
|
||||
try {
|
||||
const dir = dirname(SCREENS_FILE);
|
||||
@@ -201,7 +205,14 @@ export class ScreenManager extends EventEmitter {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const data = Array.from(this.screens.values());
|
||||
writeFileSync(SCREENS_FILE, JSON.stringify(data, null, 2));
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
|
||||
// Async write to avoid blocking event loop
|
||||
writeFile(SCREENS_FILE, json, (err) => {
|
||||
if (err) {
|
||||
console.error('[ScreenManager] Failed to save screens:', err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScreenManager] Failed to save screens:', err);
|
||||
}
|
||||
@@ -623,6 +634,14 @@ export class ScreenManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
* Call this on server shutdown.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopStatsCollection();
|
||||
}
|
||||
|
||||
// Register a session as using screen (for when session creates its own screen)
|
||||
registerScreen(screen: ScreenSession): void {
|
||||
this.screens.set(screen.sessionId, screen);
|
||||
|
||||
+6
-1
@@ -1424,11 +1424,16 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private cleanupOldTaskDescriptions(): void {
|
||||
const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS;
|
||||
// Collect keys to delete first, then delete (avoids modifying Map during iteration)
|
||||
const keysToDelete: number[] = [];
|
||||
for (const [timestamp] of this._recentTaskDescriptions) {
|
||||
if (timestamp < cutoff) {
|
||||
this._recentTaskDescriptions.delete(timestamp);
|
||||
keysToDelete.push(timestamp);
|
||||
}
|
||||
}
|
||||
for (const key of keysToDelete) {
|
||||
this._recentTaskDescriptions.delete(key);
|
||||
}
|
||||
|
||||
// Enforce size limit by removing oldest entries
|
||||
if (this._recentTaskDescriptions.size > Session.MAX_TASK_DESCRIPTIONS) {
|
||||
|
||||
+181
-99
@@ -461,6 +461,7 @@ class ClaudemanApp {
|
||||
this.subagentWindows = new Map(); // Map<agentId, { element, position }>
|
||||
this.subagentWindowZIndex = 1000;
|
||||
this.minimizedSubagents = new Map(); // Map<sessionId, Set<agentId>> - minimized to tab
|
||||
this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
|
||||
// Project Insights tracking (active Bash tools with clickable file paths)
|
||||
@@ -1917,6 +1918,10 @@ class ClaudemanApp {
|
||||
_fullRenderSessionTabs() {
|
||||
const container = this.$('sessionTabs');
|
||||
|
||||
// Clean up any orphaned dropdowns before re-rendering
|
||||
document.querySelectorAll('body > .subagent-dropdown').forEach(d => d.remove());
|
||||
this.cancelHideSubagentDropdown();
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance
|
||||
const parts = [];
|
||||
for (const [id, session] of this.sessions) {
|
||||
@@ -1958,23 +1963,25 @@ class ClaudemanApp {
|
||||
for (const agentId of minimizedAgents) {
|
||||
const agent = this.subagents.get(agentId);
|
||||
const displayName = agent?.description || agentId.substring(0, 12);
|
||||
const truncatedName = displayName.length > 30 ? displayName.substring(0, 30) + '...' : displayName;
|
||||
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
|
||||
const statusClass = agent?.status || 'idle';
|
||||
agentItems.push(`
|
||||
<div class="subagent-dropdown-item">
|
||||
<span class="subagent-dropdown-icon">🤖</span>
|
||||
<span class="subagent-dropdown-name" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${agentId}', '${sessionId}')" title="Click to restore">${this.escapeHtml(truncatedName)}</span>
|
||||
<span class="subagent-dropdown-status ${statusClass}">${statusClass}</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${agentId}', '${sessionId}')" title="Close permanently">×</span>
|
||||
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${agentId}', '${sessionId}')" title="Click to restore">
|
||||
<span class="subagent-dropdown-status ${statusClass}"></span>
|
||||
<span class="subagent-dropdown-name">${this.escapeHtml(truncatedName)}</span>
|
||||
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${agentId}', '${sessionId}')" title="Dismiss">×</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Compact badge - shows on hover, click to pin open
|
||||
return `
|
||||
<span class="tab-subagent-badge" onclick="event.stopPropagation(); app.toggleSubagentDropdown(this);" title="${minimizedAgents.size} minimized subagent${minimizedAgents.size > 1 ? 's' : ''}">
|
||||
🤖<span class="subagent-count">${minimizedAgents.size}</span>
|
||||
<div class="subagent-dropdown">
|
||||
<div class="subagent-dropdown-header">Minimized Agents</div>
|
||||
<span class="tab-subagent-badge"
|
||||
onmouseenter="app.showSubagentDropdown(this)"
|
||||
onmouseleave="app.scheduleHideSubagentDropdown(this)"
|
||||
onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">
|
||||
<span class="subagent-count">${minimizedAgents.size}</span>
|
||||
<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">
|
||||
${agentItems.join('')}
|
||||
</div>
|
||||
</span>
|
||||
@@ -2002,40 +2009,68 @@ class ClaudemanApp {
|
||||
this.saveSubagentWindowStates();
|
||||
}
|
||||
|
||||
// Toggle subagent dropdown visibility on click
|
||||
toggleSubagentDropdown(badgeEl) {
|
||||
// Show subagent dropdown on hover
|
||||
showSubagentDropdown(badgeEl) {
|
||||
this.cancelHideSubagentDropdown();
|
||||
const dropdown = badgeEl.querySelector('.subagent-dropdown');
|
||||
if (!dropdown) return;
|
||||
if (!dropdown || dropdown.classList.contains('open')) return;
|
||||
|
||||
const isOpen = dropdown.classList.contains('open');
|
||||
|
||||
// Close all other dropdowns first and move them back to their badges
|
||||
// Close other dropdowns first
|
||||
document.querySelectorAll('.subagent-dropdown.open').forEach(d => {
|
||||
d.classList.remove('open');
|
||||
// Move back to original parent if it was moved to body
|
||||
d.classList.remove('open', 'pinned');
|
||||
if (d.parentElement === document.body && d._originalParent) {
|
||||
d._originalParent.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
// Move dropdown to body to escape contain:layout clipping from .session-tabs
|
||||
dropdown._originalParent = dropdown.parentElement;
|
||||
document.body.appendChild(dropdown);
|
||||
// Move to body to escape clipping
|
||||
dropdown._originalParent = badgeEl;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
// Position the dropdown using fixed positioning
|
||||
const rect = badgeEl.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 4}px`;
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
// Position below badge
|
||||
const rect = badgeEl.getBoundingClientRect();
|
||||
dropdown.style.top = `${rect.bottom + 2}px`;
|
||||
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
||||
dropdown.style.transform = 'translateX(-50%)';
|
||||
dropdown.classList.add('open');
|
||||
}
|
||||
|
||||
dropdown.classList.add('open');
|
||||
// Schedule hide after delay (allows moving mouse to dropdown)
|
||||
scheduleHideSubagentDropdown(badgeEl) {
|
||||
this._subagentHideTimeout = setTimeout(() => {
|
||||
const dropdown = badgeEl?.querySelector?.('.subagent-dropdown') ||
|
||||
document.querySelector('.subagent-dropdown.open');
|
||||
if (dropdown && !dropdown.classList.contains('pinned')) {
|
||||
dropdown.classList.remove('open');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
// Cancel scheduled hide
|
||||
cancelHideSubagentDropdown() {
|
||||
if (this._subagentHideTimeout) {
|
||||
clearTimeout(this._subagentHideTimeout);
|
||||
this._subagentHideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Pin dropdown open on click (stays until clicking outside)
|
||||
pinSubagentDropdown(badgeEl) {
|
||||
const dropdown = document.querySelector('.subagent-dropdown.open');
|
||||
if (!dropdown) {
|
||||
this.showSubagentDropdown(badgeEl);
|
||||
return;
|
||||
}
|
||||
dropdown.classList.toggle('pinned');
|
||||
|
||||
if (dropdown.classList.contains('pinned')) {
|
||||
// Close on outside click
|
||||
const closeHandler = (e) => {
|
||||
if (!badgeEl.contains(e.target) && !dropdown.contains(e.target)) {
|
||||
dropdown.classList.remove('open');
|
||||
// Move back to original parent
|
||||
dropdown.classList.remove('open', 'pinned');
|
||||
if (dropdown._originalParent) {
|
||||
dropdown._originalParent.appendChild(dropdown);
|
||||
}
|
||||
@@ -2046,6 +2081,11 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy toggle for backwards compat
|
||||
toggleSubagentDropdown(badgeEl) {
|
||||
this.pinSubagentDropdown(badgeEl);
|
||||
}
|
||||
|
||||
// Permanently close a minimized subagent (remove from DOM and minimized set)
|
||||
permanentlyCloseMinimizedSubagent(agentId, sessionId) {
|
||||
// Remove from minimized set
|
||||
@@ -2428,44 +2468,48 @@ class ClaudemanApp {
|
||||
// Get global Ralph tracker setting
|
||||
const ralphEnabled = this.isRalphTrackerEnabledByDefault();
|
||||
|
||||
// Create multiple sessions with unique w-numbers
|
||||
// Create all sessions in parallel for speed
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const sessionName = `w${startNumber + i}-${caseName}`;
|
||||
|
||||
// Create session
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name: sessionName })
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
|
||||
// Apply global Ralph tracker setting
|
||||
// If enabled: enable the tracker
|
||||
// If disabled: disable auto-enable to prevent pattern-based activation
|
||||
await fetch(`/api/sessions/${createData.session.id}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: ralphEnabled,
|
||||
disableAutoEnable: !ralphEnabled
|
||||
})
|
||||
});
|
||||
|
||||
// Start interactive mode
|
||||
await fetch(`/api/sessions/${createData.session.id}/interactive`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Track first session
|
||||
if (i === 0) {
|
||||
firstSessionId = createData.session.id;
|
||||
}
|
||||
|
||||
this.terminal.writeln(`\x1b[90m Created session ${i}/${tabCount}: ${sessionName}\x1b[0m`);
|
||||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||||
}
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
|
||||
// Collect created session IDs
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
sessionIds.push(result.session.id);
|
||||
}
|
||||
firstSessionId = sessionIds[0];
|
||||
|
||||
// Step 2: Configure Ralph for all sessions in parallel
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/ralph-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: ralphEnabled, disableAutoEnable: !ralphEnabled })
|
||||
})
|
||||
));
|
||||
|
||||
// Step 3: Start all sessions in parallel (biggest speedup)
|
||||
this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`);
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/interactive`, { method: 'POST' })
|
||||
));
|
||||
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`);
|
||||
|
||||
// Auto-switch to the new session using selectSession (does proper refresh)
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
@@ -2505,38 +2549,49 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Create multiple shell sessions
|
||||
// Create all shell sessions in parallel
|
||||
const sessionNames = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const sessionName = `s${startNumber + i}-${caseName}`;
|
||||
sessionNames.push(`s${startNumber + i}-${caseName}`);
|
||||
}
|
||||
|
||||
// Create session with shell mode
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
// Step 1: Create all sessions in parallel
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, mode: 'shell', name: sessionName })
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (!createData.success) throw new Error(createData.error);
|
||||
body: JSON.stringify({ workingDir, mode: 'shell', name })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
|
||||
const sessionId = createData.session.id;
|
||||
const sessionIds = [];
|
||||
for (const result of createResults) {
|
||||
if (!result.success) throw new Error(result.error);
|
||||
sessionIds.push(result.session.id);
|
||||
}
|
||||
|
||||
// Start shell
|
||||
await fetch(`/api/sessions/${sessionId}/shell`, {
|
||||
method: 'POST'
|
||||
});
|
||||
// Step 2: Start all shells in parallel
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/shell`, { method: 'POST' })
|
||||
));
|
||||
|
||||
// Set active to last created
|
||||
this.activeSessionId = sessionId;
|
||||
|
||||
// Send resize
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
await fetch(`/api/sessions/${sessionId}/resize`, {
|
||||
// Step 3: Resize all in parallel
|
||||
const dims = this.fitAddon.proposeDimensions();
|
||||
if (dims) {
|
||||
await Promise.all(sessionIds.map(id =>
|
||||
fetch(`/api/sessions/${id}/resize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
|
||||
});
|
||||
}
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// Switch to first session
|
||||
if (sessionIds.length > 0) {
|
||||
this.activeSessionId = sessionIds[0];
|
||||
await this.selectSession(sessionIds[0]);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
@@ -4192,13 +4247,22 @@ class ClaudemanApp {
|
||||
// Only restore window if agent exists and is still active/idle (not completed)
|
||||
if (agent && agent.status !== 'completed') {
|
||||
this.openSubagentWindow(agentId);
|
||||
// Restore position if saved
|
||||
// Restore position if saved (with viewport bounds check)
|
||||
if (position) {
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (windowData && windowData.element) {
|
||||
windowData.element.style.left = position.left;
|
||||
windowData.element.style.top = position.top;
|
||||
windowData.position = position;
|
||||
// Parse position values and clamp to viewport
|
||||
let left = parseInt(position.left, 10) || 50;
|
||||
let top = parseInt(position.top, 10) || 120;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const windowWidth = 420;
|
||||
const windowHeight = 350;
|
||||
left = Math.max(10, Math.min(left, viewportWidth - windowWidth - 10));
|
||||
top = Math.max(10, Math.min(top, viewportHeight - windowHeight - 10));
|
||||
windowData.element.style.left = `${left}px`;
|
||||
windowData.element.style.top = `${top}px`;
|
||||
windowData.position = { left: `${left}px`, top: `${top}px` };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5545,11 +5609,18 @@ class ClaudemanApp {
|
||||
const gap = 20;
|
||||
const startX = 50;
|
||||
const startY = 120;
|
||||
const maxCols = Math.floor((window.innerWidth - startX - 50) / (windowWidth + gap)) || 1;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1;
|
||||
const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1;
|
||||
const col = windowCount % maxCols;
|
||||
const row = Math.floor(windowCount / maxCols);
|
||||
const finalX = startX + col * (windowWidth + gap);
|
||||
const finalY = startY + row * (windowHeight + gap);
|
||||
const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport
|
||||
let finalX = startX + col * (windowWidth + gap);
|
||||
let finalY = startY + row * (windowHeight + gap);
|
||||
|
||||
// Ensure window stays within viewport bounds
|
||||
finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10));
|
||||
finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10));
|
||||
|
||||
// Get parent tab position for spawn animation
|
||||
const parentTab = agent.parentSessionId
|
||||
@@ -5790,8 +5861,15 @@ class ClaudemanApp {
|
||||
if (!isDragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
win.style.left = `${startLeft + dx}px`;
|
||||
win.style.top = `${startTop + dy}px`;
|
||||
// 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));
|
||||
win.style.left = `${newLeft}px`;
|
||||
win.style.top = `${newTop}px`;
|
||||
// Throttle connection line updates during drag
|
||||
if (!dragUpdateScheduled) {
|
||||
dragUpdateScheduled = true;
|
||||
@@ -5803,7 +5881,11 @@ class ClaudemanApp {
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Save position after drag ends
|
||||
this.saveSubagentWindowStates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+64
-61
@@ -2952,125 +2952,128 @@ kbd {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Subagent Badge on Tabs */
|
||||
/* Subagent Badge on Tabs - Compact hover-triggered */
|
||||
.session-tab .tab-subagent-badge {
|
||||
position: relative;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
margin-left: 4px;
|
||||
transition: transform 0.1s, background 0.15s;
|
||||
}
|
||||
|
||||
.session-tab .tab-subagent-badge:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.session-tab .tab-subagent-badge .subagent-count {
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Subagent Dropdown - uses fixed positioning to escape overflow:hidden parents */
|
||||
/* Subagent Dropdown - compact, hover-triggered */
|
||||
.subagent-dropdown {
|
||||
display: none;
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.subagent-dropdown.open {
|
||||
display: block;
|
||||
animation: dropdownFadeIn 0.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.subagent-dropdown.pinned {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Header removed - no longer needed */
|
||||
.subagent-dropdown-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--bg-input);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subagent-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.subagent-dropdown-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.subagent-dropdown-icon {
|
||||
font-size: 0.85rem;
|
||||
/* Status dot instead of text label */
|
||||
.subagent-dropdown-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.active {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 4px var(--green);
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.idle {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.completed {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.subagent-dropdown-name {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagent-dropdown-status {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.active {
|
||||
background: var(--green);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.idle {
|
||||
background: var(--yellow);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.subagent-dropdown-status.completed {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subagent-dropdown-name {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagent-dropdown-name:hover {
|
||||
.subagent-dropdown-item:hover .subagent-dropdown-name {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.subagent-dropdown-close {
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
margin-left: auto;
|
||||
padding: 0 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.subagent-dropdown-item:hover .subagent-dropdown-close {
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.subagent-dropdown-close:hover {
|
||||
|
||||
+2
-2
@@ -3292,8 +3292,8 @@ export class WebServer extends EventEmitter {
|
||||
this.scheduledCleanupTimer = null;
|
||||
}
|
||||
|
||||
// Stop screen stats collection
|
||||
this.screenManager.stopStatsCollection();
|
||||
// Stop screen manager and flush pending saves
|
||||
this.screenManager.destroy();
|
||||
|
||||
// Clear all pending respawn start timers (from restoration grace period)
|
||||
for (const timer of this.pendingRespawnStarts.values()) {
|
||||
|
||||
Reference in New Issue
Block a user