From 7bca2e73a6ef7b5d3294be48ecb61251b63bb85b Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 21 Mar 2026 16:05:56 +0200 Subject: [PATCH] refactor: update lead member detection and enhance team management UI - Replaced instances of isLeadAgentType with isLeadMember for improved clarity in team member role checks. - Updated README to include a new built-in review workflow feature for agent task reviews. - Enhanced team detail and list views to accurately reflect current team members and their roles. - Improved CSS for step progress indicators, adding new styles for a circular stepper. - Refactored provisioning progress block to utilize a new StepProgressBar component for better visual representation of steps. --- README.md | 24 +++- src/main/services/team/TeamDataService.ts | 6 +- .../services/team/TeamMemberLogsFinder.ts | 28 ++-- .../services/team/TeamProvisioningService.ts | 13 +- .../team/ProvisioningProgressBlock.tsx | 82 +++-------- .../components/team/StepProgressBar.tsx | 127 ++++++++++++++++++ .../components/team/TeamDetailView.tsx | 2 +- src/renderer/components/team/TeamListView.tsx | 4 +- .../team/members/MemberDetailDialog.tsx | 6 +- .../team/members/MemberDetailHeader.tsx | 7 +- .../team/members/MemberHoverCard.tsx | 6 +- .../components/team/members/MemberList.tsx | 2 +- .../team/messages/MessagesPanel.tsx | 2 +- src/renderer/index.css | 45 +++++++ src/renderer/utils/memberHelpers.ts | 6 +- 15 files changed, 261 insertions(+), 99 deletions(-) create mode 100644 src/renderer/components/team/StepProgressBar.tsx diff --git a/README.md b/README.md index 49440c73..62259df8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A new approach to task management with AI agent teams. - **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams - **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own - **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment +- **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned - **Full tool visibility** — inspect exactly which tools an agent used to complete each task - **Task-specific logs and messages** — clearly see all Claude logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser @@ -73,8 +74,29 @@ A new approach to task management with AI agent teams. - **Task context is preserved** — thanks to task descriptions, comments, and attachments, all essential information about each task remains available for ongoing work and future reference +- **Workflow history** — see the full timeline of each task: when and how its status changed, which agents were involved, and every action that led to the current state + +## Comparison + +How we compare to other multi-agent orchestration tools: + +| Feature | Claude Agent Teams UI | Vibe Kanban | Dorothy | Cursor | Claude Code CLI | +|---|---|---|---|---|---| +| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ⚠️ Only via Super Agent | ❌ | ✅ Built-in (no UI) | +| **Cross-team communication** | ✅ | ❌ | ❌ | ❌ | ✅ (no UI) | +| **Kanban board** | ✅ 5 columns, real-time | ✅ | ✅ Auto-assignment | ❌ | ❌ | +| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ PR-level only | ❌ | ✅ BugBot on PRs | ❌ | +| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ | +| **Review workflow** | ✅ Agents review each other | ❌ | ❌ | ❌ | ✅ (no UI) | +| **Session analysis** | ✅ 6-category token tracking | ❌ | ⚠️ Basic stats | ❌ | ❌ | +| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ❌ | ❌ | ✅ | ❌ | +| **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ✅ | +| **Multi-agent backend** | 🗓️ Planned | ✅ 6+ agents | ✅ 3 agents | ✅ Own models | — | +| **Git worktree isolation** | ✅ Optional | ✅ Built-in | ❌ | ✅ | ✅ | +| **Price** | **Free** | Free / $30 user/mo | Free | $0–$200/mo | Claude subscription | + ## Installation No prerequisites — Claude Code can be installed and configured directly from the app. @@ -234,7 +256,7 @@ pnpm dist # macOS + Windows + Linux --- -## TODO +## Roadmap - [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. - [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 8f21767f..bcd1ab8b 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -762,7 +762,7 @@ export class TeamDataService { ): Promise<{ oldRole: string | undefined; changed: boolean }> { const { members, member } = await this.ensureMemberInMeta(teamName, memberName); if (member.removedAt) throw new Error(`Member "${memberName}" is removed`); - if (isLeadAgentType(member.agentType)) throw new Error('Cannot change team lead role'); + if (isLeadMember(member)) throw new Error('Cannot change team lead role'); const oldRole = member.role; const normalized = typeof newRole === 'string' && newRole.trim() ? newRole.trim() : undefined; @@ -838,7 +838,7 @@ export class TeamDataService { if (member.removedAt) { throw new Error(`Member "${memberName}" is already removed`); } - if (isLeadAgentType(member.agentType)) { + if (isLeadMember(member)) { throw new Error('Cannot remove team lead'); } @@ -1872,7 +1872,7 @@ export class TeamDataService { return []; } - const leadName = config.members?.find((m) => isLeadAgentType(m.agentType))?.name ?? 'team-lead'; + const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; const sessionIds = this.getRecentLeadSessionIds(config); if (sessionIds.length === 0) { return []; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 0ee8ab68..6bdfa8d5 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1,5 +1,5 @@ import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; -import { isLeadAgentType } from '@shared/utils/leadDetection'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; import { createReadStream } from 'fs'; @@ -113,7 +113,7 @@ export class TeamMemberLogsFinder { const results: MemberLogSummary[] = []; const leadMemberName = - config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead'; + config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; if (isLeadMember && config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); const leadSummary = await this.parseLeadSessionSummary( @@ -204,7 +204,7 @@ export class TeamMemberLogsFinder { const { projectDir, projectId, config, sessionIds, knownMembers } = discovery; const results: MemberLogSummary[] = []; const leadMemberName = - config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead'; + config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; if (config.leadSessionId) { const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); @@ -426,7 +426,7 @@ export class TeamMemberLogsFinder { const refs: { filePath: string; memberName: string; sortTime: number }[] = []; const seen = new Set(); const leadMemberName = - config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead'; + config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { const key = `${memberName.toLowerCase()}:${filePath}`; @@ -655,10 +655,11 @@ export class TeamMemberLogsFinder { const trimmedId = taskId.trim(); // CLI agents may use displayId (first 8 chars of UUID) in tool inputs. // Build regex that matches either form. - const displayId = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmedId) - ? trimmedId.slice(0, 8).toLowerCase() - : null; + const displayId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + trimmedId + ) + ? trimmedId.slice(0, 8).toLowerCase() + : null; const idAlternation = displayId ? `(?:${trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${displayId})` : trimmedId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -830,7 +831,7 @@ export class TeamMemberLogsFinder { if (!discovery) return null; const { config } = discovery; const leadMemberName = - config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead'; + config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase(); return { ...discovery, isLeadMember }; } @@ -1495,7 +1496,14 @@ export class TeamMemberLogsFinder { // ignore — return whatever we collected so far } - return { firstTimestamp, lastTimestamp, messageCount, lastOutputPreview, lastThinkingPreview, recentPreviews }; + return { + firstTimestamp, + lastTimestamp, + messageCount, + lastOutputPreview, + lastThinkingPreview, + recentPreviews, + }; } private extractTimestampFromLine(line: string): string | null { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6c939c82..23df85b8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3789,8 +3789,7 @@ export class TeamProvisioningService { } if (!config) return 0; - const leadName = - config.members?.find((m) => isLeadAgentType(m?.agentType))?.name?.trim() || 'team-lead'; + const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead'; let leadInboxMessages: Awaited> = []; try { @@ -5180,11 +5179,11 @@ export class TeamProvisioningService { try { const config = await this.configReader.getConfig(run.teamName); if (config?.members) { - const configLead = config.members.find((m) => isLeadAgentType(m?.agentType)); + const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || 'team-lead'; // Convert config members (excluding lead) to TeamCreateRequest member format. const configTeammates = config.members - .filter((m) => !isLeadAgentType(m?.agentType) && m?.name) + .filter((m) => !isLeadMember(m) && m?.name) .map((m) => ({ name: m.name, role: m.role ?? undefined, @@ -5779,8 +5778,7 @@ export class TeamProvisioningService { members?: { name?: string; agentType?: string }[]; }; const suffixed = (config.members ?? []).filter( - (m) => - typeof m.name === 'string' && /-\d+$/.test(m.name) && !isLeadAgentType(m.agentType) + (m) => typeof m.name === 'string' && /-\d+$/.test(m.name) && !isLeadMember(m) ); if (suffixed.length > 0) { logger.warn( @@ -7143,6 +7141,9 @@ export class TeamProvisioningService { if (typeof agentType === 'string' && isLeadAgentType(agentType)) { return true; } + // Also check by name (CLI may set agentType to "general-purpose" for leads) + const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''; + if (name === 'team-lead') return true; const leadAgentId = config.leadAgentId; return ( typeof leadAgentId === 'string' && diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 83e91ba5..329a81db 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; @@ -9,8 +8,14 @@ import { MarkdownViewer } from '../chat/viewers/MarkdownViewer'; import { CliLogsRichView } from './CliLogsRichView'; import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; +import { StepProgressBar } from './StepProgressBar'; -import type { ProvisioningStep } from './provisioningSteps'; +import type { StepProgressBarStep } from './StepProgressBar'; + +/** Pre-built step definitions for the provisioning stepper (excludes 'ready') */ +const PROVISIONING_STEPS: StepProgressBarStep[] = STEP_ORDER.filter((s) => s !== 'ready').map( + (s) => ({ key: s, label: STEP_LABELS[s] }) +); export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ @@ -122,21 +127,11 @@ export const ProvisioningProgressBlock = ({ className, }: ProvisioningProgressBlockProps): React.JSX.Element => { const elapsed = useElapsedTimer(startedAt, loading); - const [logsOpen, setLogsOpen] = useState(() => tone === 'error' && Boolean(cliLogsTail)); + const [logsOpen, setLogsOpen] = useState(() => Boolean(cliLogsTail) && loading); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const outputScrollRef = useRef(null); const isError = tone === 'error'; const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); - const spawningStepIndex = STEP_ORDER.indexOf('spawning'); - const isCliLaunchMessage = - message?.toLowerCase().includes('starting claude cli process') ?? false; - const isCliStarting = - !isError && - Boolean(cliLogsTail) && - loading && - (currentStepIndex <= spawningStepIndex || isCliLaunchMessage); - const wasCliStartingRef = useRef(false); - const hadCliStartingPhaseRef = useRef(false); // Auto-scroll assistant output useEffect(() => { @@ -158,28 +153,20 @@ export const ProvisioningProgressBlock = ({ } }, [isError, cliLogsTail]); - // Keep CLI logs visible while the launch command is still starting, - // then collapse them once the spawned process is actually running. + // Open CLI logs while loading, collapse when done (unless error). + const prevLoadingRef = useRef(loading); useEffect(() => { - if (isError || !cliLogsTail) { - wasCliStartingRef.current = false; - hadCliStartingPhaseRef.current = false; - return; + if (!isError && cliLogsTail) { + if (loading && !prevLoadingRef.current) { + // Started loading → open + setLogsOpen(true); + } else if (!loading && prevLoadingRef.current) { + // Finished loading → collapse + setLogsOpen(false); + } } - - if (isCliStarting && !wasCliStartingRef.current) { - setLogsOpen(true); - } - - if (!isCliStarting && wasCliStartingRef.current && hadCliStartingPhaseRef.current) { - setLogsOpen(false); - } - - wasCliStartingRef.current = isCliStarting; - if (isCliStarting) { - hadCliStartingPhaseRef.current = true; - } - }, [cliLogsTail, isCliStarting, isError]); + prevLoadingRef.current = loading; + }, [loading, cliLogsTail, isError]); return (
) : null} -
- {STEP_ORDER.filter((s): s is ProvisioningStep => s !== 'ready').map((step, index) => { - const isDone = currentStepIndex >= 0 && index < currentStepIndex; - const isCurrent = currentStepIndex >= 0 && index === currentStepIndex; - return ( -
- - - {index + 1} - - {STEP_LABELS[step]} - - {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( - - ) : null} -
- ); - })} +
+
- {onRemoveMember && !isLeadAgentType(member.agentType) && ( + {onRemoveMember && !isLeadMember(member) && (