@@ -1424,6 +1556,7 @@
Task Details
let searchQuery = ''; // Search query for fuzzy search
let allTasksCache = []; // Cache all tasks for search
let bulkDeleteSessionId = null; // Track session for bulk delete
+ let ownerFilter = '';
// DOM
const sessionsList = document.getElementById('sessions-list');
@@ -1720,17 +1853,16 @@
Task Details
if (res.ok) {
currentTasks = await res.json();
} else if (res.status === 404) {
- // Session has no tasks directory yet - start with empty task list
currentTasks = [];
} else {
throw new Error(`Failed to fetch tasks: ${res.status}`);
}
currentSessionId = sessionId;
+ ownerFilter = '';
renderSession();
} catch (error) {
console.error('Failed to fetch tasks:', error);
- // Clear tasks on error to avoid showing stale data
currentTasks = [];
currentSessionId = sessionId;
renderSession();
@@ -1741,6 +1873,7 @@
Task Details
try {
viewMode = 'all';
currentSessionId = null;
+ ownerFilter = '';
const res = await fetch('/api/tasks/all');
let tasks = await res.json();
if (filterProject) {
@@ -1757,6 +1890,7 @@
Task Details
function renderAllTasks() {
noSession.style.display = 'none';
sessionView.classList.add('visible');
+ document.getElementById('owner-filter-bar').classList.remove('visible');
const totalTasks = currentTasks.length;
const completed = currentTasks.filter(t => t.status === 'completed').length;
@@ -1857,10 +1991,15 @@
Task Details
// Build tooltip
const tooltip = [timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
+ const isTeam = session.isTeam;
+ const memberCount = session.memberCount || 0;
+
return `
${escapeHtml(primaryName)}
+ ${isTeam ? `👥 ${memberCount} ` : ''}
+ ${isTeam ? `ℹ ` : ''}
${hasInProgress ? ' ' : ''}
${secondaryName ? `${escapeHtml(secondaryName)}
` : ''}
@@ -1915,6 +2054,7 @@ Task Details
progressPercent.textContent = `${percent}%`;
progressBar.style.width = `${percent}%`;
+ updateOwnerFilter();
renderKanban();
renderSessions();
}
@@ -1933,6 +2073,7 @@ Task Details
#${taskId}
${isBlocked ? 'Blocked ' : ''}
+ ${task.owner ? `${escapeHtml(task.owner)} ` : ''}
${escapeHtml(task.subject)}
${sessionLabel ? `${escapeHtml(sessionLabel)}
` : ''}
@@ -1944,9 +2085,13 @@ Task Details
}
function renderKanban() {
- const pending = currentTasks.filter(t => t.status === 'pending');
- const inProgress = currentTasks.filter(t => t.status === 'in_progress');
- const completed = currentTasks.filter(t => t.status === 'completed');
+ let filtered = currentTasks;
+ if (ownerFilter) {
+ filtered = filtered.filter(t => t.owner === ownerFilter);
+ }
+ const pending = filtered.filter(t => t.status === 'pending');
+ const inProgress = filtered.filter(t => t.status === 'in_progress');
+ const completed = filtered.filter(t => t.status === 'completed');
pendingCount.textContent = pending.length;
inProgressCount.textContent = inProgress.length;
@@ -2055,6 +2200,13 @@ ${escapeHtml(task.subject)}
Status is controlled by Claude Code
+ ` : ''}
+
${task.activeForm && task.status === 'in_progress' ? `
@@ -2332,12 +2484,21 @@
${escapeHtml(task.subject)}
console.error('[SSE] fetchSessions failed:', err);
});
- // For metadata-update or matching sessionId, refresh current session view
if (currentSessionId && (data.type === 'metadata-update' || data.sessionId === currentSessionId)) {
console.log('[SSE] Refreshing current session view:', currentSessionId);
fetchTasks(currentSessionId);
}
}
+
+ if (data.type === 'team-update') {
+ console.log('[SSE] Team update:', data.teamName);
+ fetchSessions().catch(err => {
+ console.error('[SSE] fetchSessions failed:', err);
+ });
+ if (currentSessionId && data.teamName === currentSessionId) {
+ fetchTasks(currentSessionId);
+ }
+ }
};
}
@@ -2412,6 +2573,108 @@
${escapeHtml(task.subject)}
document.getElementById('session-limit').value = sessionLimit;
}
+ async function showTeamModalForSession(sessionId) {
+ const session = sessions.find(s => s.id === sessionId);
+ if (!session || !session.isTeam) return;
+ try {
+ const res = await fetch(`/api/teams/${sessionId}`);
+ if (!res.ok) return;
+ const teamConfig = await res.json();
+ showTeamModal(teamConfig, currentSessionId === sessionId ? currentTasks : []);
+ } catch (e) {
+ console.error('Failed to fetch team config:', e);
+ }
+ }
+
+ function showTeamModal(teamConfig, tasks) {
+ const modal = document.getElementById('team-modal');
+ const titleEl = document.getElementById('team-modal-title');
+ const bodyEl = document.getElementById('team-modal-body');
+
+ titleEl.textContent = `Team: ${teamConfig.team_name || teamConfig.name || 'Unknown'}`;
+
+ const ownerCounts = {};
+ tasks.forEach(t => {
+ if (t.owner) {
+ ownerCounts[t.owner] = (ownerCounts[t.owner] || 0) + 1;
+ }
+ });
+
+ const members = teamConfig.members || [];
+ const description = teamConfig.description || '';
+ const lead = members.find(m => m.agentType === 'team-lead' || m.name === 'team-lead');
+
+ let html = '';
+ if (description) {
+ html += `
"${escapeHtml(description)}"
`;
+ }
+
+ html += `
Members (${members.length})
`;
+
+ members.forEach(member => {
+ const taskCount = ownerCounts[member.name] || 0;
+ html += `
+
+
🟢 ${escapeHtml(member.name)}
+
Role: ${escapeHtml(member.agentType || 'unknown')}
+ ${member.model ? `
Model: ${escapeHtml(member.model)}
` : ''}
+
Tasks: ${taskCount} assigned
+
+ `;
+ });
+
+ const metaParts = [];
+ if (teamConfig.created_at) {
+ metaParts.push(`Created: ${new Date(teamConfig.created_at).toLocaleString()}`);
+ }
+ if (lead) {
+ metaParts.push(`Lead: ${lead.name}`);
+ }
+ if (teamConfig.working_dir) {
+ metaParts.push(`Working dir: ${teamConfig.working_dir}`);
+ }
+ if (metaParts.length > 0) {
+ html += `
${metaParts.map(p => escapeHtml(p)).join(' ')}
`;
+ }
+
+ bodyEl.innerHTML = html;
+ modal.classList.add('visible');
+
+ const keyHandler = (e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ closeTeamModal();
+ document.removeEventListener('keydown', keyHandler);
+ }
+ };
+ document.addEventListener('keydown', keyHandler);
+ }
+
+ function closeTeamModal() {
+ document.getElementById('team-modal').classList.remove('visible');
+ }
+
+ function updateOwnerFilter() {
+ const bar = document.getElementById('owner-filter-bar');
+ const select = document.getElementById('owner-filter');
+
+ const session = sessions.find(s => s.id === currentSessionId);
+ if (!session || !session.isTeam) {
+ bar.classList.remove('visible');
+ return;
+ }
+
+ bar.classList.add('visible');
+ const owners = [...new Set(currentTasks.map(t => t.owner).filter(Boolean))].sort();
+ select.innerHTML = '
All Members ' +
+ owners.map(o => `
${escapeHtml(o)} `).join('');
+ }
+
+ function filterByOwner(value) {
+ ownerFilter = value;
+ renderKanban();
+ }
+
// Init
loadTheme();
loadPreferences();
@@ -2535,6 +2798,24 @@
Deletion Result
diff --git a/server.js b/server.js
index 7389a29..f7d4b11 100644
--- a/server.js
+++ b/server.js
@@ -30,8 +30,29 @@ function getClaudeDir() {
const CLAUDE_DIR = getClaudeDir();
const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
+const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
+
+function isTeamSession(sessionId) {
+ return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
+}
+
+const teamConfigCache = new Map();
+const TEAM_CACHE_TTL = 5000;
+
+function loadTeamConfig(teamName) {
+ const cached = teamConfigCache.get(teamName);
+ if (cached && Date.now() - cached.ts < TEAM_CACHE_TTL) return cached.data;
+ try {
+ const configPath = path.join(TEAMS_DIR, teamName, 'config.json');
+ if (!existsSync(configPath)) return null;
+ const data = JSON.parse(readFileSync(configPath, 'utf8'));
+ teamConfigCache.set(teamName, { data, ts: Date.now() });
+ return data;
+ } catch (e) {
+ return null;
+ }
+}
-// Get running Claude Code containers with their project paths
// SSE clients for live updates
const clients = new Set();
@@ -231,6 +252,9 @@ app.get('/api/sessions', async (req, res) => {
// Use newest task file mtime, or fall back to directory mtime if no tasks
const modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
+ const isTeam = isTeamSession(entry.name);
+ const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
+
sessionsMap.set(entry.name, {
id: entry.name,
name: getSessionDisplayName(entry.name, meta),
@@ -243,7 +267,9 @@ app.get('/api/sessions', async (req, res) => {
inProgress,
pending,
createdAt: meta.created || null,
- modifiedAt: modifiedAt
+ modifiedAt: modifiedAt,
+ isTeam,
+ memberCount
});
}
}
@@ -296,6 +322,13 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
}
});
+// API: Get team config
+app.get('/api/teams/:name', (req, res) => {
+ const config = loadTeamConfig(req.params.name);
+ if (!config) return res.status(404).json({ error: 'Team not found' });
+ res.json(config);
+});
+
// API: Get all tasks across all sessions
app.get('/api/tasks/all', async (req, res) => {
try {
@@ -450,6 +483,24 @@ watcher.on('all', (event, filePath) => {
console.log(`Watching for changes in: ${TASKS_DIR}`);
+// Watch teams directory for config changes
+const teamsWatcher = chokidar.watch(TEAMS_DIR, {
+ persistent: true,
+ ignoreInitial: true,
+ depth: 3
+});
+
+teamsWatcher.on('all', (event, filePath) => {
+ if (filePath.endsWith('.json')) {
+ const relativePath = path.relative(TEAMS_DIR, filePath);
+ const teamName = relativePath.split(path.sep)[0];
+ teamConfigCache.delete(teamName);
+ broadcast({ type: 'team-update', teamName });
+ }
+});
+
+console.log(`Watching for team changes in: ${TEAMS_DIR}`);
+
// Also watch projects dir for metadata changes
const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
persistent: true,