fix: subagent window parent attachment and stale name after rename

Bug 1 - Wrong parent attachment with same workingDir:
- Rewrote findParentSessionForSubagent() with two-strategy approach
- Strategy 1: Sibling matching - check if another subagent with same
  Claude sessionId already has a parent assigned, use that
- Strategy 2: Heuristic fallback - if multiple sessions match by
  workingDir, prefer the most recently created session
- Removes bias toward active session that caused wrong attachment

Bug 2 - Stale parentSessionName after rename:
- Added updateSubagentParentNames() method to refresh cached names
- Called from session:updated SSE handler when session changes
- Updates both the agent object and window header DOM element
- Refreshes connection lines after name updates

Also includes unrelated plan generation wizard changes that were
already staged in app.js.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-27 01:23:12 +01:00
co-authored by Claude Opus 4.5
parent 3d52509bfe
commit 7cb7bc96e9
2 changed files with 157 additions and 29 deletions
+41
View File
@@ -323,6 +323,47 @@ Monitors Claude Code's internal background agents (the `Task` tool) in real-time
Implementation: `src/subagent-watcher.ts` - singleton `subagentWatcher` started on server boot (if enabled).
### Subagent Window Management (Frontend)
Floating subagent windows in the web UI show real-time activity from Claude Code's background agents. Each window displays tool calls, progress, and messages, with visual connection lines to the parent session tab.
**Parent Session Discovery** (`app.js:findParentSessionForSubagent()`):
1. When a subagent is discovered via SSE event, the frontend must determine which Claudeman session spawned it
2. Matching uses `workingDir` → `projectHash` conversion: `/home/user/project` becomes `-home-user-project`
3. For each session, calls `/api/sessions/{id}/subagents` which returns subagents matching that session's `workingDir`
4. First match wins: checks active session first, then iterates through other sessions
5. Once found, caches `parentSessionId` and `parentSessionName` in the agent object
**Window Visibility** (`app.js:updateSubagentWindowVisibility()`):
- "Show for Active Tab Only" setting controls whether windows are hidden when their parent session isn't active
- Windows with unknown parents (discovery pending) are always shown
- Minimized windows stay minimized regardless of visibility setting
**Tab Badge System**:
- Minimized subagents appear as a badge on their parent session's tab
- Badge dropdown allows restoring or permanently dismissing each agent
- `minimizedSubagents` Map tracks agentIds per sessionId
**Parent Matching Algorithm** (to handle multiple sessions with same workingDir):
1. **Strategy 1 - Sibling matching**: If another subagent with the same Claude `sessionId` already has a parent, use that same parent. This ensures all subagents from the same Claude session go to the same Claudeman session.
2. **Strategy 2 - WorkingDir matching with heuristics**: Find all sessions matching by `workingDir`. If multiple match, prefer the most recently created session (newest session likely spawned the subagent).
**Session Rename Handling**: When a session is renamed, `updateSubagentParentNames()` updates all subagent objects and their window headers via the `session:updated` SSE handler.
**Key Frontend Data Structures** (`app.js`):
```javascript
this.subagents = new Map(); // agentId → SubagentInfo (includes parentSessionId, parentSessionName)
this.subagentWindows = new Map(); // agentId → { element, minimized, hidden, position }
this.minimizedSubagents = new Map(); // sessionId → Set<agentId> (for tab badges)
```
**Implementation Files**:
- Server: `src/subagent-watcher.ts` (discovery, monitoring, events)
- Server: `src/web/server.ts` (SSE broadcast, REST endpoints)
- Frontend: `src/web/public/app.js:6183-6550` (window management, parent discovery, visibility)
### Session Modes
Sessions have a `mode` property (`SessionMode` type):
+116 -29
View File
@@ -1073,6 +1073,9 @@ class ClaudemanApp {
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens);
}
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
});
this.eventSource.addEventListener('session:deleted', (e) => {
@@ -2441,6 +2444,10 @@ class ClaudemanApp {
maxIterations: 10,
caseName: 'testcase',
enableRespawn: true,
// Plan generation fields
generatedPlan: null, // [{content, priority, enabled, id}] or null
planGenerated: false,
skipPlanGeneration: false,
};
showRalphWizard() {
@@ -2452,6 +2459,10 @@ class ClaudemanApp {
maxIterations: 10,
caseName: document.getElementById('quickStartCase')?.value || 'testcase',
enableRespawn: true,
// Plan generation fields
generatedPlan: null,
planGenerated: false,
skipPlanGeneration: false,
};
// Reset UI
@@ -2462,6 +2473,9 @@ class ClaudemanApp {
// Populate case selector
this.populateRalphCaseSelector();
// Reset plan generation UI
this.resetPlanGenerationUI();
// Show wizard modal
this.updateRalphWizardUI();
document.getElementById('ralphWizardModal').classList.add('active');
@@ -2510,17 +2524,30 @@ class ClaudemanApp {
this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase();
this.ralphWizardConfig.caseName = caseName;
// Move to step 2 (plan generation)
this.ralphWizardStep = 2;
this.updateRalphWizardUI();
} else if (this.ralphWizardStep === 2) {
// Must have generated or skipped plan
if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) {
this.showToast('Generate a plan or skip', 'warning');
return;
}
// Generate preview
this.updateRalphPromptPreview();
// Move to step 2
this.ralphWizardStep = 2;
// Move to step 3 (launch)
this.ralphWizardStep = 3;
this.updateRalphWizardUI();
}
}
ralphWizardBack() {
if (this.ralphWizardStep === 2) {
if (this.ralphWizardStep === 3) {
this.ralphWizardStep = 2;
this.updateRalphWizardUI();
} else if (this.ralphWizardStep === 2) {
this.ralphWizardStep = 1;
this.updateRalphWizardUI();
}
@@ -2536,14 +2563,15 @@ class ClaudemanApp {
el.classList.toggle('completed', stepNum < step);
});
// Show/hide pages
// Show/hide pages (now 3 pages)
document.getElementById('ralphWizardStep1').classList.toggle('hidden', step !== 1);
document.getElementById('ralphWizardStep2').classList.toggle('hidden', step !== 2);
document.getElementById('ralphWizardStep3').classList.toggle('hidden', step !== 3);
// Show/hide buttons
document.getElementById('ralphBackBtn').style.display = step === 1 ? 'none' : 'block';
document.getElementById('ralphNextBtn').style.display = step === 2 ? 'none' : 'block';
document.getElementById('ralphStartBtn').style.display = step === 2 ? 'block' : 'none';
document.getElementById('ralphNextBtn').style.display = step === 3 ? 'none' : 'block';
document.getElementById('ralphStartBtn').style.display = step === 3 ? 'block' : 'none';
}
updateRalphPromptPreview() {
@@ -6184,40 +6212,99 @@ class ClaudemanApp {
const agent = this.subagents.get(agentId);
if (!agent) return;
// Helper to check a session and set parent if found
const checkSession = async (sessionId, session) => {
// Strategy 1: Check if another subagent with the same Claude sessionId already has a parent
// This ensures subagents from the same Claude session go to the same Claudeman session
if (agent.sessionId) {
for (const [otherAgentId, otherAgent] of this.subagents) {
if (otherAgentId !== agentId &&
otherAgent.sessionId === agent.sessionId &&
otherAgent.parentSessionId &&
this.sessions.has(otherAgent.parentSessionId)) {
// Found a sibling subagent with an assigned parent
agent.parentSessionId = otherAgent.parentSessionId;
agent.parentSessionName = otherAgent.parentSessionName;
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
return;
}
}
}
// Strategy 2: Find all sessions that match by workingDir
const matchingSessions = [];
for (const [sessionId, session] of this.sessions) {
try {
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
if (!resp.ok) return false;
if (!resp.ok) continue;
const result = await resp.json();
const subagents = result.data || result.subagents || [];
if (subagents.some(s => s.agentId === agentId)) {
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
// Update window if already open
this.updateSubagentWindowParent(agentId);
// Update visibility based on whether this is the active session
this.updateSubagentWindowVisibility();
return true;
matchingSessions.push({ sessionId, session });
}
} catch (err) {
// Ignore errors
}
return false;
};
// First, check the active session (most likely parent for new agents)
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
const found = await checkSession(this.activeSessionId, this.sessions.get(this.activeSessionId));
if (found) return;
}
// Then check other sessions
for (const [sessionId, session] of this.sessions) {
if (sessionId === this.activeSessionId) continue; // Already checked
const found = await checkSession(sessionId, session);
if (found) return;
if (matchingSessions.length === 0) {
// No matching session found
return;
}
// If only one session matches, use it
let chosen = matchingSessions[0];
if (matchingSessions.length > 1) {
// Multiple sessions with same workingDir - use heuristics to pick the right one
// Prefer the most recently created session (newest session likely spawned the subagent)
matchingSessions.sort((a, b) => {
const aTime = new Date(a.session.createdAt || 0).getTime();
const bTime = new Date(b.session.createdAt || 0).getTime();
return bTime - aTime; // Newest first
});
chosen = matchingSessions[0];
}
// Assign the parent
agent.parentSessionId = chosen.sessionId;
agent.parentSessionName = this.getSessionName(chosen.session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
}
/**
* Update parentSessionName for all subagents belonging to a session.
* Called when a session is updated (e.g., renamed) to keep cached names fresh.
*/
updateSubagentParentNames(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
const newName = this.getSessionName(session);
let updated = false;
for (const [agentId, agent] of this.subagents) {
if (agent.parentSessionId === sessionId && agent.parentSessionName !== newName) {
agent.parentSessionName = newName;
this.subagents.set(agentId, agent);
updated = true;
// Update the window header if open
const windowData = this.subagentWindows.get(agentId);
if (windowData) {
const parentNameEl = windowData.element.querySelector('.subagent-window-parent .parent-name');
if (parentNameEl) {
parentNameEl.textContent = newName;
}
}
}
}
// Update connection lines if any names changed (visual refresh)
if (updated) {
this.updateConnectionLines();
}
}