feat: use Task tool description for subagent window titles + auto-minimize completed

- Extract short description from TaskTracker (params.description) for subagent windows
- Correlate subagents with sessions by working directory and timing
- Auto-minimize subagent windows when agents complete
- Persist minimized/open window states to server for cross-browser sync
- Restore window states on reconnection/page reload
- New API: GET/PUT /api/subagent-window-states
- State file: ~/.claudeman/subagent-window-states.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 09:06:49 +01:00
co-authored by Claude Opus 4.5
parent f95ffd12e2
commit f06240f4ef
10 changed files with 388 additions and 11 deletions
+3 -1
View File
@@ -184,7 +184,9 @@ export class RunSummaryTracker {
const now = Date.now();
// Update idle time if was previously idle
if (!this.isCurrentlyActive && this.stats.lastIdleAt) {
// Note: We count the initial idle period (from session start to first working state)
// even if lastIdleAt hasn't been set yet
if (!this.isCurrentlyActive) {
this.stats.totalTimeIdleMs += now - this.lastStatusChangeAt;
}
+22
View File
@@ -242,6 +242,28 @@ export class SubagentWatcher extends EventEmitter {
return this.agentInfo.get(agentId);
}
/**
* Update a subagent's description.
* Used to set the short description from TaskTracker when available.
*/
updateDescription(agentId: string, description: string): boolean {
const info = this.agentInfo.get(agentId);
if (info) {
info.description = description;
this.emit('subagent:updated', info);
return true;
}
return false;
}
/**
* Find sessions whose working directory matches a project hash.
* Returns the project hash for a given working directory.
*/
getProjectHashForDir(workingDir: string): string {
return this.getProjectHash(workingDir);
}
/**
* Get recent subagents (modified within specified minutes)
*/
+155 -7
View File
@@ -1661,6 +1661,15 @@ class ClaudemanApp {
}
this.renderSubagentPanel();
this.updateSubagentWindows();
// Auto-minimize completed subagent windows
if (this.subagentWindows.has(data.agentId)) {
const windowData = this.subagentWindows.get(data.agentId);
if (windowData && !windowData.minimized) {
this.closeSubagentWindow(data.agentId); // This minimizes to tab
this.saveSubagentWindowStates(); // Persist the minimized state
}
}
});
}
@@ -1722,6 +1731,9 @@ class ClaudemanApp {
this.subagents.set(s.agentId, s);
});
this.renderSubagentPanel();
// Restore subagent window states (minimized/open) after subagents are loaded
this.restoreSubagentWindowStates();
}
// Auto-select first session if any
@@ -1871,7 +1883,6 @@ class ClaudemanApp {
<span class="tab-name" data-session-id="${id}">${this.escapeHtml(name)}</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-summary" onclick="event.stopPropagation(); app.openRunSummary('${id}')" title="Run Summary" data-session-id="${id}">&#x1F4CA;</span>
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${id}')">&times;</span>
</div>`);
@@ -1929,6 +1940,9 @@ class ClaudemanApp {
// Re-render tabs to update badge
this.renderSessionTabs();
// Persist the state change
this.saveSubagentWindowStates();
}
// Toggle subagent dropdown visibility on click
@@ -1992,6 +2006,9 @@ class ClaudemanApp {
// Re-render tabs to update badge
this.renderSessionTabs();
this.updateConnectionLines();
// Persist the state change
this.saveSubagentWindowStates();
}
getSessionName(session) {
@@ -3355,18 +3372,21 @@ class ClaudemanApp {
closeSessionOptions() {
this.editingSessionId = null;
// Stop run summary auto-refresh if it was running
this.stopRunSummaryAutoRefresh();
document.getElementById('sessionOptionsModal').classList.remove('active');
}
// ========== Run Summary Modal ==========
async openRunSummary(sessionId) {
// Open session options modal and switch to summary tab
this.openSessionOptions(sessionId);
this.switchOptionsTab('summary');
this.runSummarySessionId = sessionId;
this.runSummaryFilter = 'all';
// Show modal
document.getElementById('runSummaryModal').classList.add('active');
// Reset filter buttons
document.querySelectorAll('.run-summary-filters .filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === 'all');
@@ -3379,12 +3399,14 @@ class ClaudemanApp {
closeRunSummary() {
this.runSummarySessionId = null;
this.stopRunSummaryAutoRefresh();
document.getElementById('runSummaryModal').classList.remove('active');
// Close session options modal (summary is now a tab in it)
this.closeSessionOptions();
}
async refreshRunSummary() {
if (!this.runSummarySessionId) return;
await this.loadRunSummary(this.runSummarySessionId);
const sessionId = this.runSummarySessionId || this.editingSessionId;
if (!sessionId) return;
await this.loadRunSummary(sessionId);
}
toggleRunSummaryAutoRefresh() {
@@ -3632,6 +3654,12 @@ class ClaudemanApp {
document.getElementById('respawn-tab').classList.toggle('hidden', tabName !== 'respawn');
document.getElementById('context-tab').classList.toggle('hidden', tabName !== 'context');
document.getElementById('ralph-tab').classList.toggle('hidden', tabName !== 'ralph');
document.getElementById('summary-tab').classList.toggle('hidden', tabName !== 'summary');
// Load run summary data when switching to summary tab
if (tabName === 'summary' && this.editingSessionId) {
this.loadRunSummary(this.editingSessionId);
}
}
getRalphConfig() {
@@ -4008,6 +4036,120 @@ class ClaudemanApp {
return this.loadAppSettingsFromStorage();
}
// ========== Subagent Window State Persistence ==========
/**
* Save subagent window states (minimized/open) to server for cross-browser persistence.
* Called when a window is minimized, restored, or auto-minimized on completion.
*/
async saveSubagentWindowStates() {
// Build state object: which agents are minimized per session
const minimizedState = {};
for (const [sessionId, agentIds] of this.minimizedSubagents) {
minimizedState[sessionId] = Array.from(agentIds);
}
// Also track which windows are open (not minimized)
const openWindows = [];
for (const [agentId, windowData] of this.subagentWindows) {
if (!windowData.minimized) {
openWindows.push({
agentId,
position: windowData.position || null
});
}
}
const windowStates = { minimized: minimizedState, open: openWindows };
// Save to localStorage for quick restore
localStorage.setItem('claudeman-subagent-window-states', JSON.stringify(windowStates));
// Save to server for cross-browser persistence
try {
await fetch('/api/subagent-window-states', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(windowStates)
});
} catch (err) {
console.error('Failed to save subagent window states to server:', err);
}
}
/**
* Load subagent window states from server (or localStorage fallback).
* Called on page load to restore minimized/open window states.
*/
async loadSubagentWindowStates() {
let states = null;
// Try server first for cross-browser sync
try {
const res = await fetch('/api/subagent-window-states');
if (res.ok) {
states = await res.json();
// Also update localStorage
localStorage.setItem('claudeman-subagent-window-states', JSON.stringify(states));
}
} catch (err) {
console.error('Failed to load subagent window states from server:', err);
}
// Fallback to localStorage
if (!states) {
try {
const saved = localStorage.getItem('claudeman-subagent-window-states');
if (saved) {
states = JSON.parse(saved);
}
} catch (err) {
console.error('Failed to load subagent window states from localStorage:', err);
}
}
return states || { minimized: {}, open: [] };
}
/**
* Restore subagent window states after loading subagents.
* Opens windows that were open before, keeps minimized ones minimized.
*/
async restoreSubagentWindowStates() {
const states = await this.loadSubagentWindowStates();
// Restore minimized state
for (const [sessionId, agentIds] of Object.entries(states.minimized || {})) {
if (Array.isArray(agentIds) && agentIds.length > 0) {
// Verify agents still exist
const validAgents = agentIds.filter(id => this.subagents.has(id));
if (validAgents.length > 0) {
this.minimizedSubagents.set(sessionId, new Set(validAgents));
}
}
}
// Restore open windows (for non-completed agents only)
for (const { agentId, position } of (states.open || [])) {
const agent = this.subagents.get(agentId);
// 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
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;
}
}
}
}
this.renderSessionTabs(); // Update tab badges
}
// ========== Help Modal ==========
showHelp() {
@@ -5402,6 +5544,9 @@ class ClaudemanApp {
// No animation, just update connection lines
this.updateConnectionLines();
}
// Persist the state change (new window opened)
this.saveSubagentWindowStates();
}
closeSubagentWindow(agentId) {
@@ -5424,6 +5569,9 @@ class ClaudemanApp {
// Update tab badge to show minimized agents
this.renderSessionTabs();
// Persist the state change
this.saveSubagentWindowStates();
} else {
// No parent session - fully remove the window
windowData.element.remove();
+53 -1
View File
@@ -326,6 +326,7 @@
<button class="modal-tab-btn active" data-tab="respawn" onclick="app.switchOptionsTab('respawn')">Respawn</button>
<button class="modal-tab-btn" data-tab="context" onclick="app.switchOptionsTab('context')">Context</button>
<button class="modal-tab-btn" data-tab="ralph" onclick="app.switchOptionsTab('ralph')">Ralph / Todo</button>
<button class="modal-tab-btn" data-tab="summary" onclick="app.switchOptionsTab('summary')">Summary</button>
</div>
<div class="modal-body">
<!-- Respawn Tab Content -->
@@ -478,11 +479,62 @@
<button class="btn-toolbar btn-primary" onclick="app.saveRalphConfig()">Save Ralph Config</button>
</div>
</div><!-- End ralph-tab -->
<!-- Summary Tab Content -->
<div class="modal-tab-content hidden" id="summary-tab">
<!-- Stats Cards -->
<div class="run-summary-stats" id="runSummaryStats">
<div class="stat-card">
<div class="stat-value" id="rsStat-cycles">0</div>
<div class="stat-label">Respawn Cycles</div>
</div>
<div class="stat-card">
<div class="stat-value" id="rsStat-tokens">0</div>
<div class="stat-label">Tokens Used</div>
</div>
<div class="stat-card">
<div class="stat-value" id="rsStat-active">0m</div>
<div class="stat-label">Active Time</div>
</div>
<div class="stat-card">
<div class="stat-value" id="rsStat-issues">0</div>
<div class="stat-label">Issues</div>
</div>
</div>
<!-- Filter Buttons -->
<div class="run-summary-filters">
<button class="filter-btn active" data-filter="all" onclick="app.filterRunSummary('all')">All</button>
<button class="filter-btn" data-filter="errors" onclick="app.filterRunSummary('errors')">Errors</button>
<button class="filter-btn" data-filter="warnings" onclick="app.filterRunSummary('warnings')">Warnings</button>
<button class="filter-btn" data-filter="respawn" onclick="app.filterRunSummary('respawn')">Respawn</button>
<button class="filter-btn" data-filter="idle" onclick="app.filterRunSummary('idle')">Idle/Working</button>
</div>
<!-- Timeline -->
<div class="run-summary-timeline" id="runSummaryTimeline">
<p class="empty-message">Select a session to view summary</p>
</div>
<!-- Footer with actions -->
<div class="run-summary-footer">
<span class="run-summary-session-info" id="runSummarySessionInfo"></span>
<div class="run-summary-actions">
<label class="auto-refresh-label">
<input type="checkbox" id="runSummaryAutoRefresh" onchange="app.toggleRunSummaryAutoRefresh()">
Auto-refresh
</label>
<button class="btn-toolbar btn-sm" onclick="app.refreshRunSummary()" title="Refresh summary">&#x21BB; Refresh</button>
<button class="btn-toolbar btn-sm" onclick="app.exportRunSummary('json')" title="Export as JSON">Export JSON</button>
<button class="btn-toolbar btn-sm" onclick="app.exportRunSummary('md')" title="Export as Markdown">Export MD</button>
</div>
</div>
</div><!-- End summary-tab -->
</div>
</div>
</div>
<!-- Run Summary Modal -->
<!-- Run Summary Modal (legacy, kept for backwards compatibility) -->
<div class="modal" id="runSummaryModal">
<div class="modal-backdrop" onclick="app.closeRunSummary()"></div>
<div class="modal-content modal-lg">
+15
View File
@@ -2103,6 +2103,21 @@ body {
color: var(--text-dim);
}
.run-summary-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.run-summary-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.modal-footer-actions {
display: flex;
align-items: center;
+88
View File
@@ -321,6 +321,13 @@ export class WebServer extends EventEmitter {
*/
private setupSubagentWatcherListeners(): void {
subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => {
// Try to get a better description from the session's TaskTracker
// The TaskTracker has access to params.description from the Task tool call
const betterDescription = this.getTaskDescriptionForSubagent(info);
if (betterDescription && betterDescription !== info.description) {
info.description = betterDescription;
subagentWatcher.updateDescription(info.agentId, betterDescription);
}
this.broadcast('subagent:discovered', info);
});
@@ -349,6 +356,54 @@ export class WebServer extends EventEmitter {
});
}
/**
* Get the short description from TaskTracker for a discovered subagent.
*
* When Claude Code spawns a subagent via the Task tool, it passes two parameters:
* - description: Short title like "Explore Python scripts" (3-5 words)
* - prompt: Full detailed task prompt
*
* The TaskTracker parses the Task tool call and has access to params.description,
* while the SubagentWatcher only sees the JSONL file which contains the full prompt.
*
* This method correlates them by finding the session whose working directory
* matches the subagent's project hash, then checking for recent tasks.
*/
private getTaskDescriptionForSubagent(info: SubagentInfo): string | undefined {
// Find the session whose working directory matches this subagent's project
for (const session of this.sessions.values()) {
const sessionProjectHash = subagentWatcher.getProjectHashForDir(session.workingDir);
if (sessionProjectHash === info.projectHash) {
// Found the owning session - check its TaskTracker for recent tasks
// Get all tasks from the tracker
const allTasks = Array.from(session.taskTracker.getAllTasks().values());
if (allTasks.length > 0) {
// Sort by start time descending to get most recent first
const recentTasks = allTasks.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
// Check if there's a task that started recently (within 5 seconds of subagent discovery)
const subagentStartTime = new Date(info.startedAt).getTime();
for (const task of recentTasks) {
const taskStartTime = task.startTime || 0;
const timeDiff = Math.abs(subagentStartTime - taskStartTime);
// If the task started within 5 seconds of the subagent, use its description
if (timeDiff < 5000 && task.description) {
return task.description;
}
}
// Fallback: use the most recent running task's description
const runningTask = recentTasks.find(t => t.status === 'running');
if (runningTask?.description) {
return runningTask.description;
}
}
break; // Found the session, no need to continue
}
}
return undefined;
}
private async setupRoutes(): Promise<void> {
// Serve static files
await this.app.register(fastifyStatic, {
@@ -1639,6 +1694,36 @@ export class WebServer extends EventEmitter {
}
});
// ============ Subagent Window State Endpoints ============
// Persists minimized/open window states for cross-browser sync
const windowStatesPath = join(homedir(), '.claudeman', 'subagent-window-states.json');
this.app.get('/api/subagent-window-states', async () => {
try {
if (existsSync(windowStatesPath)) {
const content = readFileSync(windowStatesPath, 'utf-8');
return JSON.parse(content);
}
} catch (err) {
console.error('Failed to read subagent window states:', err);
}
return { minimized: {}, open: [] };
});
this.app.put('/api/subagent-window-states', async (req) => {
const states = req.body as Record<string, unknown>;
try {
const dir = dirname(windowStatesPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(windowStatesPath, JSON.stringify(states, null, 2));
return { success: true };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ============ Screen Management Endpoints ============
// Get all tracked screens with stats
@@ -2186,6 +2271,9 @@ export class WebServer extends EventEmitter {
this.broadcast('session:completion', { id: session.id, result, cost });
this.broadcast('session:updated', session.toDetailedState());
this.persistSessionState(session);
// Track tokens in run summary (completion event has updated token values)
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens);
});
session.on('exit', (code) => {