Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 259 additions & 55 deletions lib/modules/agent_network/components/running_agents_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,54 +15,79 @@ class RunningAgentsBar extends StatelessComponent {
final List<AgentMetadata> agents;
final int selectedIndex;

/// Calculate max log line width based on number of agents
/// More agents = shorter lines so they fit
int _getMaxLogWidth(int agentCount) {
if (agentCount <= 2) return 45;
if (agentCount == 3) return 40;
if (agentCount == 4) return 32;
if (agentCount == 5) return 26;
return 22; // 6+ agents
}

@override
Component build(BuildContext context) {
final maxLogWidth = _getMaxLogWidth(agents.length);

return Row(
crossAxisAlignment: CrossAxisAlignment.start, // Align all columns at top
children: [
for (int i = 0; i < agents.length; i++)
_RunningAgentBarItem(
_AgentColumn(
agent: agents[i],
isSelected: i == selectedIndex,
maxLogWidth: maxLogWidth,
),
],
);
}
}

class _RunningAgentBarItem extends StatefulComponent {
/// A single agent column: badge on top, tool log directly below
class _AgentColumn extends StatefulComponent {
final AgentMetadata agent;
final bool isSelected;
final int maxLogWidth;

const _RunningAgentBarItem({required this.agent, required this.isSelected});
const _AgentColumn({
required this.agent,
required this.isSelected,
required this.maxLogWidth,
});

@override
State<_RunningAgentBarItem> createState() => _RunningAgentBarItemState();
State<_AgentColumn> createState() => _AgentColumnState();
}

class _RunningAgentBarItemState extends State<_RunningAgentBarItem> {
static const _spinnerFrames = [
'⠋',
'⠙',
'⠹',
'⠸',
'⠼',
'⠴',
'⠦',
'⠧',
'⠇',
'⠏',
];
class _AgentColumnState extends State<_AgentColumn> {
static const _spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
static const _maxTotalLines = 3; // Total lines under badge (task name + tool logs)

/// Tools to skip entirely in the log (noise)
static const _skipTools = {'TodoWrite', 'mcp__vide-task-management__setTaskName', 'mcp__vide-task-management__setAgentTaskName'};

// Static storage for typing animation state per agent (persists across tab switches)
static final Map<String, _TypingState> _typingStates = {};

Timer? _spinnerTimer;
int _spinnerIndex = 0;
AgentStatus? _lastInferredStatus;

// Typing animation timer (per-widget, but state is stored in static map)
Timer? _typingTimer;

@override
void initState() {
super.initState();
_spinnerTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
setState(() => _spinnerIndex = (_spinnerIndex + 1) % _spinnerFrames.length);
});
}

void _startSpinner() {
if (_spinnerTimer != null) return; // Already running
_spinnerTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
setState(() {
_spinnerIndex = (_spinnerIndex + 1) % _spinnerFrames.length;
});
setState(() => _spinnerIndex = (_spinnerIndex + 1) % _spinnerFrames.length);
});
}

Expand All @@ -89,10 +114,67 @@ class _RunningAgentBarItemState extends State<_RunningAgentBarItem> {

@override
void dispose() {
_stopSpinner();
_spinnerTimer?.cancel();
_typingTimer?.cancel();
// Clean up typing state to prevent memory leak when agent terminates
_typingStates.remove(component.agent.id);
super.dispose();
}

_TypingState _getTypingState() {
final agentId = component.agent.id;
return _typingStates.putIfAbsent(agentId, () => _TypingState());
}

void _maybeStartTypingAnimation(String? taskName) {
final state = _getTypingState();

if (taskName == null || taskName.isEmpty) {
state.displayedText = '';
state.lastTaskName = null;
return;
}

// If animation already complete for this task name, nothing to do
if (taskName == state.lastTaskName && state.typingIndex >= taskName.length) {
return;
}

// If same task name and animation in progress, continue it
if (taskName == state.lastTaskName) {
// Resume animation from where it left off
_typingTimer?.cancel();
_typingTimer = Timer.periodic(const Duration(milliseconds: 30), (_) {
if (mounted && state.typingIndex < taskName.length) {
setState(() {
state.typingIndex++;
state.displayedText = taskName.substring(0, state.typingIndex);
});
} else {
_typingTimer?.cancel();
}
});
return;
}

// New task name - start fresh animation
_typingTimer?.cancel();
state.lastTaskName = taskName;
state.typingIndex = 0;
state.displayedText = '';

_typingTimer = Timer.periodic(const Duration(milliseconds: 30), (_) {
if (mounted && state.typingIndex < taskName.length) {
setState(() {
state.typingIndex++;
state.displayedText = taskName.substring(0, state.typingIndex);
});
} else {
_typingTimer?.cancel();
}
});
}

String _getStatusIndicator(AgentStatus status) {
return switch (status) {
AgentStatus.working => _spinnerFrames[_spinnerIndex],
Expand All @@ -119,13 +201,6 @@ class _RunningAgentBarItemState extends State<_RunningAgentBarItem> {
};
}

String _buildAgentDisplayName(AgentMetadata agent) {
if (agent.taskName != null && agent.taskName!.isNotEmpty) {
return '${agent.name} - ${agent.taskName}';
}
return agent.name;
}

/// Infer the actual status based on both explicit status and Claude's processing state.
/// This provides safeguards against agents forgetting to call setAgentStatus.
AgentStatus _inferActualStatus(
Expand All @@ -151,17 +226,97 @@ class _RunningAgentBarItemState extends State<_RunningAgentBarItem> {
return explicitStatus;
}

List<ToolUseResponse> _getRecentToolUses(Conversation conversation, int count) {
final toolUses = <ToolUseResponse>[];
for (final message in conversation.messages.reversed) {
if (message.role == MessageRole.assistant) {
for (final response in message.responses.reversed) {
if (response is ToolUseResponse) {
// Skip tools we want to filter out
if (_skipTools.contains(response.toolName)) continue;
toolUses.add(response);
if (toolUses.length >= count) return toolUses;
}
}
}
}
return toolUses;
}

/// Translate MCP tool names to friendly labels
String _translateToolName(String name) {
// Vide agent tools
if (name == 'mcp__vide-agent__setAgentStatus') return 'Status';
if (name == 'mcp__vide-agent__spawnAgent') return 'Spawn';
if (name == 'mcp__vide-agent__sendMessageToAgent') return 'Message';
if (name == 'mcp__vide-agent__terminateAgent') return 'Terminate';
// Vide memory tools
if (name == 'mcp__vide-memory__memorySave') return 'Save';
if (name == 'mcp__vide-memory__memoryRetrieve') return 'Recall';
if (name == 'mcp__vide-memory__memoryList') return 'ListMemory';
// Git tools
if (name.startsWith('mcp__vide-git__')) return name.replaceFirst('mcp__vide-git__', '');
// Dart tools
if (name.startsWith('mcp__dart__')) return name.replaceFirst('mcp__dart__', '');
// Generic MCP prefix removal
if (name.startsWith('mcp__')) {
final parts = name.split('__');
return parts.length > 1 ? parts.last : name;
}
return name;
}

String? _formatToolUse(ToolUseResponse toolUse) {
final name = toolUse.toolName;

// Skip noisy tools
if (_skipTools.contains(name)) return null;

final params = toolUse.parameters;
final friendlyName = _translateToolName(name);

String? paramValue;
if (params.containsKey('query')) {
paramValue = params['query'] as String;
} else if (params.containsKey('pattern')) {
paramValue = params['pattern'] as String;
} else if (params.containsKey('file_path')) {
paramValue = (params['file_path'] as String).split('/').last;
} else if (params.containsKey('command')) {
paramValue = params['command'] as String;
} else if (params.containsKey('url')) {
final uri = Uri.tryParse(params['url'] as String);
paramValue = uri?.host ?? params['url'] as String;
} else if (params.containsKey('prompt')) {
paramValue = params['prompt'] as String;
} else if (params.containsKey('message')) {
paramValue = params['message'] as String;
} else if (params.containsKey('agentType')) {
// For spawn, show agent type
paramValue = params['agentType'] as String;
} else if (params.containsKey('status')) {
// For status updates
paramValue = params['status'] as String;
} else if (params.containsKey('key')) {
// For memory operations
paramValue = params['key'] as String;
}

String result = paramValue != null ? '$friendlyName("$paramValue")' : friendlyName;
final maxWidth = component.maxLogWidth;
if (result.length > maxWidth) {
return '${result.substring(0, maxWidth - 3)}...';
}
return result;
}

@override
Component build(BuildContext context) {
final theme = VideTheme.of(context);
final explicitStatus = context.watch(
agentStatusProvider(component.agent.id),
);
final explicitStatus = context.watch(agentStatusProvider(component.agent.id));

// Get Claude's processing status from the stream
final claudeStatusAsync = context.watch(
claudeStatusProvider(component.agent.id),
);
final claudeStatusAsync = context.watch(claudeStatusProvider(component.agent.id));
final claudeStatus = claudeStatusAsync.valueOrNull ?? ClaudeStatus.ready;

// Infer actual status - use Claude's status to correct agent status if needed
Expand All @@ -174,35 +329,84 @@ class _RunningAgentBarItemState extends State<_RunningAgentBarItem> {
final indicatorTextColor = _getIndicatorTextColor(status, theme);
final statusIndicator = _getStatusIndicator(status);

// Get task name from agent metadata (set via setAgentTaskName MCP tool)
final taskName = component.agent.taskName;

// Trigger typing animation if task name changed (only for sub-agents)
if (component.agent.type != 'main') {
_maybeStartTypingAnimation(taskName);
}

// Get typing state for this agent
final typingState = _getTypingState();
final displayedTaskName = typingState.displayedText;

// Get tool log (only for sub-agents when not idle)
// Total lines under badge is capped at 3 (task name counts as 1 if present)
final showToolLog = status != AgentStatus.idle && component.agent.type != 'main';
final hasTaskName = displayedTaskName.isNotEmpty && component.agent.type != 'main' && status != AgentStatus.idle;
final maxToolLogLines = hasTaskName ? _maxTotalLines - 1 : _maxTotalLines;

// Get conversation for tool logs
final client = context.watch(claudeProvider(component.agent.id));
final conversation = client?.currentConversation;
final recentToolUses = showToolLog && conversation != null
? _getRecentToolUses(conversation, maxToolLogLines)
: <ToolUseResponse>[];

return Padding(
padding: EdgeInsets.only(right: 1),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(color: indicatorColor),
child: Text(
statusIndicator,
style: TextStyle(color: indicatorTextColor),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(color: theme.base.surface),
child: Text(
_buildAgentDisplayName(component.agent),
style: TextStyle(
color: theme.base.onSurface,
fontWeight: component.isSelected ? FontWeight.bold : null,
decoration: component.isSelected
? TextDecoration.underline
: null,
// Badge row
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(color: indicatorColor),
child: Text(statusIndicator, style: TextStyle(color: indicatorTextColor)),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(color: Colors.grey),
child: Text(
component.agent.name,
style: TextStyle(
color: Colors.white,
fontWeight: component.isSelected ? FontWeight.bold : null,
decoration: component.isSelected ? TextDecoration.underline : null,
),
),
),
],
),
// Task name with typing animation (below badge, above tool log, only for sub-agents)
if (displayedTaskName.isNotEmpty && component.agent.type != 'main' && status != AgentStatus.idle)
Text(
displayedTaskName,
style: TextStyle(color: Colors.white.withOpacity(0.8)),
),
// Tool log (below task name, only for sub-agents)
if (recentToolUses.isNotEmpty)
for (int i = recentToolUses.length - 1; i >= 0; i--)
Text(
'${i == 0 ? '↳' : ' '} ${_formatToolUse(recentToolUses[i])}',
style: TextStyle(
color: Colors.white.withOpacity(i == 0 ? 0.7 : 0.5),
),
),
],
),
);
}
}

/// Stores typing animation state per agent (persists across tab switches)
class _TypingState {
String? lastTaskName;
String displayedText = '';
int typingIndex = 0;
}
Loading