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.
This commit is contained in:
iliya 2026-03-21 16:05:56 +02:00
parent 7dda73aa74
commit 7bca2e73a6
15 changed files with 261 additions and 99 deletions

View file

@ -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
</details>
## 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)

View file

@ -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 [];

View file

@ -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<string>();
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 {

View file

@ -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<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
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' &&

View file

@ -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<HTMLDivElement>(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 (
<div
@ -225,33 +212,8 @@ export const ProvisioningProgressBlock = ({
{message}
</p>
) : null}
<div className="mt-2 flex items-center justify-center gap-1 overflow-x-auto pb-0.5">
{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 (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone &&
'border-[var(--step-done-border)] bg-[var(--step-done-bg)] text-[var(--step-done-text)]',
isCurrent &&
'border-[var(--step-current-border)] bg-[var(--step-current-bg)] text-[var(--step-current-text)]'
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
</div>
);
})}
<div className="mt-2 px-2">
<StepProgressBar steps={PROVISIONING_STEPS} currentIndex={currentStepIndex} />
</div>
<div className="mt-2">
<button

View file

@ -0,0 +1,127 @@
import { cn } from '@renderer/lib/utils';
import { Check } from 'lucide-react';
export interface StepProgressBarStep {
key: string;
label: string;
}
export interface StepProgressBarProps {
steps: StepProgressBarStep[];
/** 0-based index of the current step, -1 if not started */
currentIndex: number;
className?: string;
}
/**
* Circular step progress indicator with animated connecting lines.
*
* - Completed steps: green circle with checkmark
* - Current step: green outlined circle with pulsing ring + number
* - Pending steps: gray circle with number
* - Lines between steps animate with a green fill for completed transitions
*/
export const StepProgressBar = ({
steps,
currentIndex,
className,
}: StepProgressBarProps): React.JSX.Element => {
return (
<div className={cn('flex items-start justify-center', className)}>
{steps.map((step, index) => {
const isDone = currentIndex >= 0 && index < currentIndex;
const isCurrent = currentIndex >= 0 && index === currentIndex;
const isLast = index === steps.length - 1;
// The connecting line between this step and the next
const lineState: 'done' | 'active' | 'pending' =
isDone && !isLast ? 'done' : isCurrent && !isLast ? 'active' : 'pending';
return (
<div
key={step.key}
className="flex items-start"
style={{ flex: isLast ? '0 0 auto' : '1 1 0%' }}
>
{/* Step circle + label column */}
<div className="flex flex-col items-center" style={{ width: 56 }}>
{/* Circle */}
<div
className={cn(
'relative flex items-center justify-center rounded-full transition-all duration-300',
// Sizing
'size-7',
// Done state
isDone && 'bg-[var(--stepper-done)] shadow-[0_0_8px_var(--stepper-done-glow)]',
// Current state
isCurrent && 'border-2 border-[var(--stepper-current)] bg-transparent',
// Pending state
!isDone &&
!isCurrent &&
'border border-[var(--stepper-pending-border)] bg-[var(--stepper-pending)]'
)}
style={
isCurrent
? { animation: 'stepper-pulse-ring 2s ease-in-out infinite' }
: undefined
}
>
{isDone ? (
<Check className="size-3.5 text-white" strokeWidth={3} />
) : (
<span
className={cn(
'text-[11px] font-semibold leading-none',
isCurrent
? 'text-[var(--stepper-current)]'
: 'text-[var(--stepper-pending-text)]'
)}
>
{index + 1}
</span>
)}
</div>
{/* Label */}
<span
className={cn(
'mt-1.5 text-center text-[10px] leading-tight transition-colors duration-300',
isDone || isCurrent
? 'font-medium text-[var(--stepper-label-active)]'
: 'text-[var(--stepper-label)]'
)}
>
{step.label}
</span>
</div>
{/* Connecting line */}
{!isLast && (
<div
className="relative mt-3.5 h-[2px] flex-1 overflow-hidden"
style={{ minWidth: 16 }}
>
{/* Background track */}
<div className="absolute inset-0 rounded-full bg-[var(--stepper-line)]" />
{lineState === 'done' ? (
/* Fully filled green line */
<div className="absolute inset-0 rounded-full bg-[var(--stepper-line-done)]" />
) : lineState === 'active' ? (
/* Cyclic sweep — green highlight sliding left-to-right in a loop */
<div
className="absolute top-0 h-full rounded-full bg-[var(--stepper-line-done)]"
style={{
width: '40%',
animation: 'stepper-line-sweep 1.2s ease-in-out infinite',
}}
/>
) : null}
</div>
)}
</div>
);
})}
</div>
);
};

View file

@ -1809,7 +1809,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadAgentType(m.agentType))}
currentMembers={data.members.filter((m) => !isLeadMember(m))}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}

View file

@ -23,7 +23,7 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import {
CheckCircle,
Clock,
@ -488,7 +488,7 @@ export const TeamListView = (): React.JSX.Element => {
const existingNames = teams.map((t) => t.teamName);
const uniqueName = generateUniqueName(teamName, existingNames);
const members = (data.members ?? [])
.filter((m) => !m.removedAt && !isLeadAgentType(m.agentType))
.filter((m) => !m.removedAt && !isLeadMember(m))
.map((m) => {
let role = m.role;
if (!role && m.agentType && m.agentType !== 'general-purpose') {

View file

@ -4,7 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
@ -99,7 +99,7 @@ export const MemberDetailDialog = ({
member={member}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
}
@ -187,7 +187,7 @@ export const MemberDetailDialog = ({
<ListPlus size={14} />
Assign Task
</Button>
{onRemoveMember && !isLeadAgentType(member.agentType) && (
{onRemoveMember && !isLeadMember(member) && (
<Button
variant="outline"
size="sm"

View file

@ -10,7 +10,7 @@ import {
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { Pencil } from 'lucide-react';
import { MemberRoleEditor } from './MemberRoleEditor';
@ -48,10 +48,7 @@ export const MemberDetailHeader = ({
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
const canEditRole =
!isLeadAgentType(member.agentType) &&
!member.removedAt &&
!isTeamProvisioning &&
!!onUpdateRole;
!isLeadMember(member) && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
return (
<div className="flex items-center gap-3">

View file

@ -15,7 +15,7 @@ import {
getMemberDotClass,
getPresenceLabel,
} from '@renderer/utils/memberHelpers';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
@ -63,13 +63,13 @@ export const MemberHoverCard = ({
member,
isTeamAlive,
false,
isLeadAgentType(member.agentType) ? leadActivity : undefined
isLeadMember(member) ? leadActivity : undefined
);
const dotClass = getMemberDotClass(
member,
isTeamAlive,
false,
isLeadAgentType(member.agentType) ? leadActivity : undefined
isLeadMember(member) ? leadActivity : undefined
);
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks

View file

@ -106,7 +106,7 @@ export const MemberList = ({
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadAgentType(member.agentType) ? leadActivity : undefined}
leadActivity={isLeadMember(member) ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}

View file

@ -112,7 +112,7 @@ export const MessagesPanel = memo(function MessagesPanel({
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const handleExpandContent = useCallback(() => {
composerTextareaRef.current?.focus();
// no-op: user is reading expanded content, not composing
}, []);
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');

View file

@ -233,6 +233,18 @@
--step-warning-text: #fde68a;
--step-warning-border: rgba(245, 158, 11, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1);
/* Circular stepper */
--stepper-done: #22c55e;
--stepper-done-glow: rgba(34, 197, 94, 0.3);
--stepper-current: #22c55e;
--stepper-current-ring: rgba(34, 197, 94, 0.25);
--stepper-pending: rgba(255, 255, 255, 0.15);
--stepper-pending-text: #71717a;
--stepper-pending-border: rgba(255, 255, 255, 0.12);
--stepper-line: rgba(255, 255, 255, 0.08);
--stepper-line-done: #22c55e;
--stepper-label: #a1a1aa;
--stepper-label-active: #fafafa;
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(255, 255, 255, 0.04);
--color-section-bg-open: rgba(255, 255, 255, 0.07);
@ -637,6 +649,18 @@
--step-warning-text: #b45309;
--step-warning-border: rgba(180, 83, 9, 0.4);
--step-warning-bg: rgba(245, 158, 11, 0.1);
/* Circular stepper — light */
--stepper-done: #16a34a;
--stepper-done-glow: rgba(22, 163, 74, 0.2);
--stepper-current: #16a34a;
--stepper-current-ring: rgba(22, 163, 74, 0.18);
--stepper-pending: rgba(0, 0, 0, 0.06);
--stepper-pending-text: #a1a1aa;
--stepper-pending-border: rgba(0, 0, 0, 0.1);
--stepper-line: rgba(0, 0, 0, 0.08);
--stepper-line-done: #16a34a;
--stepper-label: #71717a;
--stepper-label-active: #18181b;
/* Collapsible section backgrounds (sidebar) */
--color-section-bg: rgba(0, 0, 0, 0.04);
--color-section-bg-open: rgba(0, 0, 0, 0.07);
@ -1158,3 +1182,24 @@ body {
pointer-events: none;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8));
}
/* Step progress bar — cyclic sweep on active connecting line */
@keyframes stepper-line-sweep {
0% {
left: -40%;
}
100% {
left: 100%;
}
}
/* Step progress bar — pulse ring on current step */
@keyframes stepper-pulse-ring {
0%,
100% {
box-shadow: 0 0 0 0 var(--stepper-current-ring);
}
50% {
box-shadow: 0 0 0 5px transparent;
}
}

View file

@ -3,7 +3,7 @@ import {
MEMBER_COLOR_PALETTE,
normalizeMemberColorName,
} from '@shared/constants/memberColors';
import { isLeadAgentType } from '@shared/utils/leadDetection';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import type {
LeadActivityState,
@ -43,7 +43,7 @@ export function getMemberDotClass(
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
if (leadActivity && isLeadAgentType(member.agentType)) {
if (leadActivity && isLeadMember(member)) {
return leadActivity === 'active'
? `${STATUS_DOT_COLORS.active} animate-pulse`
: STATUS_DOT_COLORS.active;
@ -63,7 +63,7 @@ export function getPresenceLabel(
if (member.status === 'terminated') return 'terminated';
if (isTeamProvisioning) return 'connecting';
if (isTeamAlive === false) return 'offline';
if (leadActivity && isLeadAgentType(member.agentType)) {
if (leadActivity && isLeadMember(member)) {
if (leadActivity === 'active') {
return leadContextPercent != null && leadContextPercent > 0
? `processing (${Math.round(leadContextPercent)}%)`