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:
arkon
2026-01-26 19:42:01 +01:00
co-authored by Claude Opus 4.5
parent 47622917e6
commit 45332b6438
7 changed files with 289 additions and 170 deletions
+13 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">&times;</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">&times;</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
View File
@@ -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
View File
@@ -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()) {