Compare commits
4 Commits
82c12554d0
...
agent/rex/
| Author | SHA1 | Date | |
|---|---|---|---|
| d06caeab8e | |||
| 49a9a95086 | |||
|
|
c8ca182af0 | ||
|
|
d5a85c4ed0 |
@@ -1,72 +0,0 @@
|
||||
namespace ControlCenter.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines which side of the Control Center dashboard a minion occupies.
|
||||
/// </summary>
|
||||
public enum MinionSide
|
||||
{
|
||||
/// <summary>Development side — Rex, Dex, Hex.</summary>
|
||||
Dev,
|
||||
|
||||
/// <summary>Business side — Larry, Mel, Buzz.</summary>
|
||||
Business
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual state of a minion sprite, derived from the agent's
|
||||
/// <see cref="AgentStatus"/>. Maps Active/Idle/Thinking/Error
|
||||
/// to frontend animation states.
|
||||
/// </summary>
|
||||
public enum MinionState
|
||||
{
|
||||
/// <summary>Agent is actively processing — minion shows working animation.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Agent is idle — minion shows idle/patrolling animation.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Agent is thinking (LLM call in flight) — minion shows thinking animation.</summary>
|
||||
Thinking,
|
||||
|
||||
/// <summary>Agent encountered an error — minion shows error/distress animation.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static mapping entry that associates an agent ID with a minion's
|
||||
/// display side and position index within that side.
|
||||
///
|
||||
/// <para>Position indices are zero-based within each side. The dev side
|
||||
/// has Rex at 0, Dex at 1, and Hex at 2. The business side has
|
||||
/// Larry at 0, Mel at 1, and Buzz at 2.</para>
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "rex", "dex".</param>
|
||||
/// <param name="Side">Which side of the dashboard the minion occupies.</param>
|
||||
/// <param name="PositionIndex">Zero-based position index within the side.</param>
|
||||
/// <param name="DisplayName">Human-readable name, e.g. "Rex".</param>
|
||||
public record AgentMinionMapping(
|
||||
string AgentId,
|
||||
MinionSide Side,
|
||||
int PositionIndex,
|
||||
string DisplayName
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Real-time minion state update pushed to SignalR clients
|
||||
/// when an agent's status changes. Combines the static mapping
|
||||
/// (who/where) with the dynamic state (what the minion is doing).
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "rex".</param>
|
||||
/// <param name="DisplayName">Human-readable minion name, e.g. "Rex".</param>
|
||||
/// <param name="Side">Which side of the dashboard — Dev or Business.</param>
|
||||
/// <param name="PositionIndex">Position within the side (0-based).</param>
|
||||
/// <param name="State">Current minion animation state.</param>
|
||||
/// <param name="Timestamp">ISO 8601 timestamp of the state change.</param>
|
||||
public record MinionStateUpdate(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
MinionSide Side,
|
||||
int PositionIndex,
|
||||
MinionState State,
|
||||
string Timestamp
|
||||
);
|
||||
@@ -52,11 +52,6 @@ builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<GatewayEventBridgeService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayEventBridgeService>());
|
||||
|
||||
// ── Agent-Minion Mapper Service ────────────────────────────
|
||||
// Maps agents to minion sprites/positions and publishes state
|
||||
// updates through SignalR.
|
||||
builder.Services.AddSingleton<AgentMinionMapperService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Middleware ──────────────────────────────────────────────
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
using ControlCenter.Hubs;
|
||||
using ControlCenter.Models;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that maps Linear agents to minion sprites and positions
|
||||
/// in the Control Center dashboard.
|
||||
///
|
||||
/// <para>Static mappings define where each minion appears:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Dev side: Rex (0), Dex (1), Hex (2)</item>
|
||||
/// <item>Business side: Larry (0), Mel (1), Buzz (2)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Dynamic state is derived from the agent's <see cref="AgentStatus"/>:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Active</c> → <see cref="MinionState.Active"/></item>
|
||||
/// <item><c>Idle</c> → <see cref="MinionState.Idle"/></item>
|
||||
/// <item><c>Thinking</c> → <see cref="MinionState.Thinking"/></item>
|
||||
/// <item><c>Error</c> → <see cref="MinionState.Error"/></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>State updates are published through the <see cref="AgentStatusHub"/>
|
||||
/// SignalR hub so that connected clients can animate minion sprites
|
||||
/// in real time.</para>
|
||||
/// </summary>
|
||||
public class AgentMinionMapperService
|
||||
{
|
||||
private readonly ILogger<AgentMinionMapperService> _logger;
|
||||
private readonly IHubContext<AgentStatusHub, IAgentStatusClient> _hubContext;
|
||||
|
||||
/// <summary>
|
||||
/// Static agent-to-minion mapping table. Defines which side and position
|
||||
/// each agent's minion occupies on the dashboard.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, AgentMinionMapping> Mappings = new()
|
||||
{
|
||||
// ── Dev Side ──────────────────────────────────
|
||||
["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"),
|
||||
["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"),
|
||||
["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"),
|
||||
|
||||
// ── Business Side ─────────────────────────────
|
||||
["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"),
|
||||
["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"),
|
||||
["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="AgentStatus"/> string values to <see cref="MinionState"/>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, MinionState> StatusToMinionState = new()
|
||||
{
|
||||
["active"] = MinionState.Active,
|
||||
["idle"] = MinionState.Idle,
|
||||
["thinking"] = MinionState.Thinking,
|
||||
["error"] = MinionState.Error,
|
||||
};
|
||||
|
||||
public AgentMinionMapperService(
|
||||
ILogger<AgentMinionMapperService> logger,
|
||||
IHubContext<AgentStatusHub, IAgentStatusClient> hubContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minion mapping for a given agent ID.
|
||||
/// Returns null if the agent is not mapped to a minion position.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "rex", "dex".</param>
|
||||
/// <returns>The mapping record, or null if unmapped.</returns>
|
||||
public AgentMinionMapping? GetMapping(string agentId)
|
||||
{
|
||||
return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all minion mappings, ordered by side then position index.
|
||||
/// </summary>
|
||||
/// <returns>All mappings, sorted for consistent display order.</returns>
|
||||
public IReadOnlyList<AgentMinionMapping> GetAllMappings()
|
||||
{
|
||||
return Mappings.Values
|
||||
.OrderBy(m => m.Side)
|
||||
.ThenBy(m => m.PositionIndex)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an agent status string to a <see cref="MinionState"/>.
|
||||
/// Falls back to <see cref="MinionState.Idle"/> for unrecognized statuses.
|
||||
/// </summary>
|
||||
/// <param name="status">Agent status string: "active", "idle", "thinking", or "error".</param>
|
||||
/// <returns>The corresponding minion state.</returns>
|
||||
public MinionState StatusToState(string status)
|
||||
{
|
||||
return StatusToMinionState.GetValueOrDefault(
|
||||
status?.ToLowerInvariant() ?? string.Empty,
|
||||
MinionState.Idle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a minion state update through SignalR when an agent's
|
||||
/// status changes. Only publishes for agents that have a minion mapping.
|
||||
///
|
||||
/// <para>This is the primary integration point: the
|
||||
/// <see cref="GatewayEventBridgeService"/> calls this method
|
||||
/// whenever it detects a status change from the OpenClaw Gateway.</para>
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent whose status changed, e.g. "dex".</param>
|
||||
/// <param name="status">The new status string: "active", "idle", "thinking", or "error".</param>
|
||||
/// <returns>A task that completes when the SignalR message has been sent.</returns>
|
||||
public async Task PublishMinionStateUpdateAsync(string agentId, string status)
|
||||
{
|
||||
var mapping = GetMapping(agentId);
|
||||
if (mapping is null)
|
||||
{
|
||||
_logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var minionState = StatusToState(status);
|
||||
var update = new MinionStateUpdate(
|
||||
AgentId: mapping.AgentId,
|
||||
DisplayName: mapping.DisplayName,
|
||||
Side: mapping.Side,
|
||||
PositionIndex: mapping.PositionIndex,
|
||||
State: minionState,
|
||||
Timestamp: DateTime.UtcNow.ToString("o")
|
||||
);
|
||||
|
||||
// Broadcast to the fleet group (all subscribers)
|
||||
await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentStatusHub.AgentGroupName(agentId);
|
||||
await _hubContext.Clients.Group(agentGroup)
|
||||
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
|
||||
|
||||
_logger.LogInformation(
|
||||
"Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})",
|
||||
agentId, minionState, mapping.Side, mapping.PositionIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current minion state for all mapped agents, suitable
|
||||
/// for building an initial fleet snapshot.
|
||||
/// </summary>
|
||||
/// <returns>All minion mappings with their current (idle) state.</returns>
|
||||
public IReadOnlyList<MinionStateUpdate> GetFullMinionState()
|
||||
{
|
||||
return Mappings.Values
|
||||
.OrderBy(m => m.Side)
|
||||
.ThenBy(m => m.PositionIndex)
|
||||
.Select(m => new MinionStateUpdate(
|
||||
AgentId: m.AgentId,
|
||||
DisplayName: m.DisplayName,
|
||||
Side: m.Side,
|
||||
PositionIndex: m.PositionIndex,
|
||||
State: MinionState.Idle,
|
||||
Timestamp: DateTime.UtcNow.ToString("o")))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a status string to an <see cref="AgentStatusUpdate"/>
|
||||
/// for SignalR push. Uses the mapping table for display names and roles.
|
||||
/// </summary>
|
||||
private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status)
|
||||
{
|
||||
var mapping = GetMapping(agentId);
|
||||
var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..];
|
||||
|
||||
return new AgentStatusUpdate(
|
||||
AgentId: agentId,
|
||||
DisplayName: displayName,
|
||||
Role: mapping is not null
|
||||
? $"{mapping.Side} Agent"
|
||||
: "Agent",
|
||||
Status: status,
|
||||
CurrentTask: null,
|
||||
SessionKey: string.Empty,
|
||||
Channel: string.Empty,
|
||||
LastActivity: DateTime.UtcNow.ToString("o"),
|
||||
ErrorMessage: status == "error" ? "Agent encountered an error" : null
|
||||
);
|
||||
}
|
||||
}
|
||||
28
frontend/src/app/pages/hub/hub-page.component.scss
Normal file
28
frontend/src/app/pages/hub/hub-page.component.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
// ============================================================================
|
||||
// Hub Page — Responsive AgentCard Grid
|
||||
// Desktop (≥1024px): 2×2 grid
|
||||
// Mobile (<1024px): single-column stack
|
||||
// ============================================================================
|
||||
|
||||
.hub-page {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
padding: var(--cc-section-padding, 16px);
|
||||
min-height: 400px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hub-page__placeholder {
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
// Desktop / kiosk breakpoint — 2-column grid
|
||||
@media (min-width: 1024px) {
|
||||
.hub-page {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.hub-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
.hub-page__placeholder {
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 16px;
|
||||
}
|
||||
`],
|
||||
styleUrl: './hub-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HubPageComponent {}
|
||||
@@ -48,6 +48,17 @@ html {
|
||||
// These are NOT part of the M3 tonal palette; they are semantic overrides.
|
||||
// ---------------------------------------------------------------------------
|
||||
:root {
|
||||
// --- Tactical Dark Mode color palette (CUB-47) ---
|
||||
--color-surface: #0F172A;
|
||||
--color-surface-light: #1E293B;
|
||||
--color-primary: #38BDF8;
|
||||
--color-secondary: #2DD4BF;
|
||||
--color-accent: #A78BFA;
|
||||
--color-danger: #F87171;
|
||||
--color-text-primary: #FFFFFF;
|
||||
--color-text-secondary: #94A3B8;
|
||||
--color-border: #334155;
|
||||
|
||||
// --- Status colors ---
|
||||
--status-active: #38BDF8;
|
||||
--status-idle: #2DD4BF;
|
||||
@@ -90,7 +101,7 @@ html {
|
||||
// Global Body Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
body {
|
||||
background-color: var(--cc-background);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--cc-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user