mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user