Compare commits
10 Commits
agent/hex/
...
agent/rex/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5147997764 | |||
| 17a28a33e1 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 | |||
| 8b8cb8210c | |||
| 4a2e660a4a | |||
| 07d40d729f | |||
| 8da593c450 | |||
| c906cd46ad |
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# Control Center — Environment Configuration Template
|
||||
# =============================================================================
|
||||
# Copy this file to `.env` and fill in real values.
|
||||
# Never commit `.env` — it is in .gitignore by default.
|
||||
# =============================================================================
|
||||
|
||||
# ── Database ────────────────────────────────────────────────────────────────
|
||||
POSTGRES_DB=controlcenter
|
||||
POSTGRES_USER=controlcenter
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
BACKEND_PORT=8080
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=development
|
||||
|
||||
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated allowed origins. Use "*" for local dev.
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ── OpenClaw Gateway ──────────────────────────────────────────────────────────
|
||||
# URL to the OpenClaw gateway agent status endpoint.
|
||||
# In Docker Compose, use the container name or host.docker.internal for the
|
||||
# gateway running outside Compose.
|
||||
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||
|
||||
# How often to poll the gateway for agent state updates.
|
||||
GATEWAY_POLL_INTERVAL=5s
|
||||
21
backend/.gitignore
vendored
21
backend/.gitignore
vendored
@@ -1,21 +0,0 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.pdb
|
||||
|
||||
## IDE
|
||||
.vs/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
## Environment
|
||||
.env
|
||||
@@ -1,88 +0,0 @@
|
||||
using ControlCenter.Api.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ControlCenter.Api.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity type configuration for the agents table.
|
||||
/// Enforces snake_case naming, required fields, and index design.
|
||||
/// </summary>
|
||||
public class AgentConfiguration : IEntityTypeConfiguration<Agent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Agent> builder)
|
||||
{
|
||||
// Table name — snake_case
|
||||
builder.ToTable("agents");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
// Status — stored as PostgreSQL enum via Npgsql
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasColumnType("agent_status")
|
||||
.IsRequired();
|
||||
|
||||
// Task — nullable text
|
||||
builder.Property(a => a.Task)
|
||||
.HasColumnName("task")
|
||||
.HasColumnType("text");
|
||||
|
||||
// Progress — nullable integer (0–100)
|
||||
builder.Property(a => a.Progress)
|
||||
.HasColumnName("progress");
|
||||
|
||||
// Session key — required, not null
|
||||
builder.Property(a => a.SessionKey)
|
||||
.HasColumnName("session_key")
|
||||
.HasColumnType("text")
|
||||
.IsRequired();
|
||||
|
||||
// Channel — required, not null
|
||||
builder.Property(a => a.Channel)
|
||||
.HasColumnName("channel")
|
||||
.HasColumnType("text")
|
||||
.IsRequired();
|
||||
|
||||
// Last activity — required, defaults to now()
|
||||
builder.Property(a => a.LastActivity)
|
||||
.HasColumnName("last_activity")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired();
|
||||
|
||||
// Created at — auto-set on insert
|
||||
builder.Property(a => a.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired()
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
// Updated at — auto-set on insert and update
|
||||
builder.Property(a => a.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired()
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
// Indexes
|
||||
// Sessions are looked up by session_key frequently
|
||||
builder.HasIndex(a => a.SessionKey)
|
||||
.HasDatabaseName("ix_agents_session_key")
|
||||
.IsUnique();
|
||||
|
||||
// Agents are filtered by channel for channel-specific queries
|
||||
builder.HasIndex(a => a.Channel)
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
// Agents are filtered by status for fleet health monitoring
|
||||
builder.HasIndex(a => a.Status)
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
// Check constraint: progress must be 0–100 if present
|
||||
builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)"));
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Exclude the separate ControlCenter sub-project from this project's compilation -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="ControlCenter/**/*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@ControlCenter.Api_HostAddress = http://localhost:5178
|
||||
|
||||
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
18
backend/ControlCenter/.gitignore
vendored
18
backend/ControlCenter/.gitignore
vendored
@@ -1,18 +0,0 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.pdb
|
||||
*.xml
|
||||
|
||||
## IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>CS1591</NoWarn>
|
||||
<RootNamespace>ControlCenter</RootNamespace>
|
||||
<AssemblyName>ControlCenter</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,71 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ControlCenter.Services;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for querying agent fleet status.
|
||||
/// Provides the initial data load for the Command Hub,
|
||||
/// while real-time updates flow through the AgentStatus SignalR hub.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/agents</c> — Returns all known agents with current status</item>
|
||||
/// <item><c>GET /api/agents/{agentId}</c> — Returns a specific agent's status</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AgentsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<AgentsController> _logger;
|
||||
private readonly GatewayEventBridgeService _bridgeService;
|
||||
|
||||
public AgentsController(
|
||||
ILogger<AgentsController> logger,
|
||||
GatewayEventBridgeService bridgeService)
|
||||
{
|
||||
_logger = logger;
|
||||
_bridgeService = bridgeService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current fleet status — all known agents with their latest state.
|
||||
/// This is the initial load endpoint; subsequent updates arrive via SignalR.
|
||||
/// </summary>
|
||||
/// <returns>An array of agent card data for the entire fleet.</returns>
|
||||
/// <response code="200">Returns the fleet snapshot.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(AgentCardData[]), StatusCodes.Status200OK)]
|
||||
public IActionResult GetAgents()
|
||||
{
|
||||
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||
_logger.LogDebug("Fleet snapshot requested: {Count} agents", snapshot.Length);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status of a specific agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <returns>The agent's current card data.</returns>
|
||||
/// <response code="200">Returns the agent's status.</response>
|
||||
/// <response code="404">Agent not found in the fleet state.</response>
|
||||
[HttpGet("{agentId}")]
|
||||
[ProducesResponseType(typeof(AgentCardData), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetAgent(string agentId)
|
||||
{
|
||||
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||
var agent = snapshot.FirstOrDefault(a =>
|
||||
a.Id.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (agent is null)
|
||||
{
|
||||
_logger.LogWarning("Agent not found: {AgentId}", agentId);
|
||||
return NotFound(new { error = $"Agent '{agentId}' not found" });
|
||||
}
|
||||
|
||||
return Ok(agent);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for sending control commands to agents.
|
||||
/// Provides the Command Hub's action endpoints for agent lifecycle control.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>POST /api/command/stop/{agentId}</c> — Stop/abort an agent's active session</item>
|
||||
/// <item><c>POST /api/command/restart/{agentId}</c> — Restart an agent</item>
|
||||
/// <item><c>POST /api/command/steer/{agentId}</c> — Inject a message into an agent's session</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Commands are forwarded to the OpenClaw Gateway via the
|
||||
/// WebSocket bridge service. The Gateway handles the actual execution.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CommandController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<CommandController> _logger;
|
||||
|
||||
public CommandController(ILogger<CommandController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops (aborts) an agent's active session.
|
||||
/// Sends an abort command to the OpenClaw Gateway.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to stop.</param>
|
||||
/// <returns>Confirmation of the stop command.</returns>
|
||||
/// <response code="200">Stop command sent successfully.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpPost("stop/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult StopAgent(string agentId)
|
||||
{
|
||||
_logger.LogInformation("Stop command received for agent {AgentId}", agentId);
|
||||
|
||||
// TODO: Forward to Gateway via bridge service
|
||||
// await _bridgeService.SendRpcAsync("sessions.abort", new { sessionKey = ... });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "stop",
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts an agent by aborting the current session and allowing
|
||||
/// a new one to start on the next incoming message.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to restart.</param>
|
||||
/// <returns>Confirmation of the restart command.</returns>
|
||||
/// <response code="200">Restart command sent successfully.</response>
|
||||
[HttpPost("restart/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult RestartAgent(string agentId)
|
||||
{
|
||||
_logger.LogInformation("Restart command received for agent {AgentId}", agentId);
|
||||
|
||||
// TODO: Forward to Gateway — abort current session + signal restart
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "restart",
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steers (injects a message into) an agent's active session.
|
||||
/// Used by operators to redirect an agent's task mid-execution.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to steer.</param>
|
||||
/// <param name="request">The steering message to inject.</param>
|
||||
/// <returns>Confirmation of the steer command.</returns>
|
||||
/// <response code="200">Steer command sent successfully.</response>
|
||||
/// <response code="400">Missing or empty message.</response>
|
||||
[HttpPost("steer/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult SteerAgent(string agentId, [FromBody] SteerRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
{
|
||||
return BadRequest(new { error = "Message is required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Steer command received for agent {AgentId}: {Message}",
|
||||
agentId, request.Message.Length > 100
|
||||
? request.Message[..100] + "..." : request.Message);
|
||||
|
||||
// TODO: Forward to Gateway via bridge service
|
||||
// await _bridgeService.SendRpcAsync("sessions.steer", new { sessionKey = ..., message = request.Message });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "steer",
|
||||
message = request.Message,
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request body for the steer command.
|
||||
/// </summary>
|
||||
/// <param name="Message">The message to inject into the agent's session.</param>
|
||||
public record SteerRequest(string Message);
|
||||
@@ -1,87 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for querying agent session logs.
|
||||
/// Provides historical message and tool call logs for a specific agent.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/logs/{agentId}</c> — Returns recent logs for an agent</item>
|
||||
/// <item><c>GET /api/logs/{agentId}/tools</c> — Returns recent tool calls for an agent</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Log data is sourced from the OpenClaw Gateway's transcript files.
|
||||
/// The Gateway's <c>logs.tail</c> RPC provides the raw data, and this
|
||||
/// controller formats it for the frontend.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LogsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<LogsController> _logger;
|
||||
|
||||
public LogsController(ILogger<LogsController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent session logs for a specific agent.
|
||||
/// Returns the last N messages from the agent's active session transcript.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <param name="limit">Maximum number of log entries to return (default: 50, max: 200).</param>
|
||||
/// <returns>An array of log entries for the agent.</returns>
|
||||
/// <response code="200">Returns the agent's recent logs.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpGet("{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetLogs(string agentId, [FromQuery] int limit = 50)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 200);
|
||||
|
||||
_logger.LogDebug("Logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||
|
||||
// TODO: Implement log retrieval by calling the Gateway's logs.tail RPC
|
||||
// or reading transcript files. For now, return an empty array as the
|
||||
// bridge service will provide this data when fully integrated.
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
logs = Array.Empty<object>(),
|
||||
count = 0,
|
||||
hasMore = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent tool call logs for a specific agent.
|
||||
/// Returns the last N tool invocations from the agent's session.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
/// <param name="limit">Maximum number of tool entries to return (default: 20, max: 100).</param>
|
||||
/// <returns>An array of tool call entries for the agent.</returns>
|
||||
/// <response code="200">Returns the agent's recent tool calls.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpGet("{agentId}/tools")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetToolLogs(string agentId, [FromQuery] int limit = 20)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
_logger.LogDebug("Tool logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||
|
||||
// TODO: Implement tool log retrieval. Return empty for now.
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
tools = Array.Empty<object>(),
|
||||
count = 0,
|
||||
hasMore = false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for real-time agent status updates in the Command Hub.
|
||||
///
|
||||
/// <para>Usage flow:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Client connects to <c>/hubs/agent-status</c></item>
|
||||
/// <item>Client calls <see cref="JoinFleet"/> to subscribe to all agent updates</item>
|
||||
/// <item>Client calls <see cref="JoinAgentGroup"/> to subscribe to a specific agent</item>
|
||||
/// <item>Server pushes <see cref="IAgentStatusClient.AgentStatusChanged"/>
|
||||
/// and <see cref="IAgentStatusClient.AgentTaskProgress"/> events</item>
|
||||
/// <item>Client calls <see cref="GetFleetSnapshot"/> for initial state on connect</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Group naming:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fleet group: <c>fleet</c> — receives all agent updates</item>
|
||||
/// <item>Agent group: <c>agent:{agentId}</c> — receives updates for one agent</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Typed client: <see cref="IAgentStatusClient"/> — all server-to-client
|
||||
/// calls go through this interface for compile-time safety.</para>
|
||||
///
|
||||
/// <para>Architecture note: This hub bridges OpenClaw Gateway WebSocket events
|
||||
/// to SignalR clients. A background service (<see cref="Services.GatewayEventBridgeService"/>)
|
||||
/// subscribes to Gateway events and pushes them through this hub's extension methods.</para>
|
||||
/// </summary>
|
||||
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||
{
|
||||
private readonly ILogger<AgentStatusHub> _logger;
|
||||
|
||||
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an agent status update to all connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Any connected client (or server-side caller) can invoke this method
|
||||
/// to push a status update to every subscriber. The DTO is converted to
|
||||
/// an <see cref="AgentStatusUpdate"/> record and relayed through the
|
||||
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="update">The agent status update DTO to broadcast.</param>
|
||||
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Broadcasting status update for agent {AgentId}: {Status}",
|
||||
update.AgentId, update.Status);
|
||||
|
||||
var agentUpdate = update.ToUpdate();
|
||||
|
||||
// Broadcast to all connected clients
|
||||
await Clients.All.AgentStatusChanged(agentUpdate);
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentGroupName(update.AgentId);
|
||||
await Clients.Group(agentGroup).AgentStatusChanged(agentUpdate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to the fleet group.
|
||||
/// Once joined, the client will receive all agent status changes
|
||||
/// and task progress updates across the entire fleet.
|
||||
/// </summary>
|
||||
public async Task JoinFleet()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from the fleet group.
|
||||
/// </summary>
|
||||
public async Task LeaveFleet()
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to a specific agent's group.
|
||||
/// Once joined, the client will receive updates only for that agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <exception cref="HubException">Thrown if agentId is null or empty.</exception>
|
||||
public async Task JoinAgentGroup(string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
throw new HubException("agentId is required");
|
||||
|
||||
var groupName = AgentGroupName(agentId);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined agent group {GroupName}",
|
||||
Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from a specific agent's group.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
public async Task LeaveAgentGroup(string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId)) return;
|
||||
|
||||
var groupName = AgentGroupName(agentId);
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the current fleet state.
|
||||
/// Called by clients on initial connection to get the full picture
|
||||
/// before incremental updates begin arriving.
|
||||
/// </summary>
|
||||
/// <returns>An array of <see cref="AgentCardData"/> representing all known agents.</returns>
|
||||
public Task<AgentCardData[]> GetFleetSnapshot()
|
||||
{
|
||||
// The fleet state is managed by the GatewayEventBridgeService.
|
||||
// For now, return an empty array — the bridge service will push
|
||||
// updates as they arrive from the Gateway.
|
||||
_logger.LogDebug("Fleet snapshot requested by {ConnectionId}", Context.ConnectionId);
|
||||
return Task.FromResult(Array.Empty<AgentCardData>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
|
||||
/// SignalR automatically removes disconnected connections from all groups.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR group name for the entire fleet (all agents).
|
||||
/// </summary>
|
||||
internal const string FleetGroupName = "fleet";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SignalR group name for a specific agent.
|
||||
/// Format: <c>agent:{agentId}</c> (lowercase for consistency).
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
internal static string AgentGroupName(string agentId) =>
|
||||
$"agent:{agentId.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for pushing real-time agent updates through
|
||||
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>These methods are intended to be called from background services
|
||||
/// (e.g., <see cref="Services.GatewayEventBridgeService"/>) or other
|
||||
/// server-side code that detects an agent state change.</para>
|
||||
/// </summary>
|
||||
public static class AgentStatusHubExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all clients subscribed to
|
||||
/// the fleet group and the specific agent's group.
|
||||
///
|
||||
/// <para>Call this from any background service when an agent's
|
||||
/// operational status changes (e.g., the Gateway reports a
|
||||
/// session transition from "running" to "done").</para>
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushAgentStatusAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdate update)
|
||||
{
|
||||
// Broadcast to the fleet group (all subscribers)
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentStatusChanged(update);
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentStatusHub.AgentGroupName(update.AgentId);
|
||||
await hubContext.Clients.Group(agentGroup)
|
||||
.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a task progress update to all clients subscribed to
|
||||
/// the fleet group and the specific agent's group.
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="progress">The task progress update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushTaskProgressAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
TaskProgressUpdate progress)
|
||||
{
|
||||
// Broadcast to the fleet group
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentTaskProgress(progress);
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentStatusHub.AgentGroupName(progress.AgentId);
|
||||
await hubContext.Clients.Group(agentGroup)
|
||||
.AgentTaskProgress(progress);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ControlCenter.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||
/// Defines the methods the server can invoke on connected clients
|
||||
/// to push real-time agent status and task progress updates.
|
||||
///
|
||||
/// <para>All server-to-client calls go through this interface for
|
||||
/// compile-time safety — matching the pattern used by the
|
||||
/// Extrudex PrinterHub.</para>
|
||||
/// </summary>
|
||||
public interface IAgentStatusClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all subscribed clients.
|
||||
/// Fired whenever an agent's operational status changes
|
||||
/// (e.g., idle → active, active → thinking, active → error).
|
||||
/// </summary>
|
||||
/// <param name="update">The full status update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentStatusChanged(AgentStatusUpdate update);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a task progress update to all subscribed clients.
|
||||
/// Fired when an agent reports progress on its current task.
|
||||
/// </summary>
|
||||
/// <param name="progress">The task progress update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentTaskProgress(TaskProgressUpdate progress);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
namespace ControlCenter;
|
||||
|
||||
/// <summary>
|
||||
/// Agent operational status derived from OpenClaw Gateway session activity.
|
||||
/// Maps to the frontend AgentStatus type: 'active' | 'idle' | 'thinking' | 'error'.
|
||||
/// </summary>
|
||||
public enum AgentStatus
|
||||
{
|
||||
/// <summary>Agent is currently processing a turn.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Agent completed its last turn; no active work.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>LLM call in flight; tokens streaming.</summary>
|
||||
Thinking,
|
||||
|
||||
/// <summary>Agent encountered an unhandled error.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended lifecycle status including offline — not all agents have active sessions.
|
||||
/// Used internally; clients only see <see cref="AgentStatus"/> (offline maps to idle).
|
||||
/// </summary>
|
||||
public enum AgentLifecycleStatus
|
||||
{
|
||||
Active,
|
||||
Idle,
|
||||
Thinking,
|
||||
Error,
|
||||
Offline
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushed to SignalR clients when an agent's status changes.
|
||||
/// Matches the TypeScript <c>AgentStatusUpdate</c> interface from the design spec.
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <param name="DisplayName">Human-readable name, e.g. "Otto".</param>
|
||||
/// <param name="Role">Role description, e.g. "Orchestrator Agent".</param>
|
||||
/// <param name="Status">Current operational status.</param>
|
||||
/// <param name="CurrentTask">Description of the current task, if any.</param>
|
||||
/// <param name="SessionKey">Full session key, e.g. "agent:otto:telegram:direct:8787451565".</param>
|
||||
/// <param name="Channel">Channel the agent is operating on, e.g. "telegram".</param>
|
||||
/// <param name="LastActivity">ISO 8601 timestamp of last activity.</param>
|
||||
/// <param name="ErrorMessage">Error message when status is 'error'.</param>
|
||||
public record AgentStatusUpdate(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
string Status,
|
||||
string? CurrentTask,
|
||||
string SessionKey,
|
||||
string Channel,
|
||||
string LastActivity,
|
||||
string? ErrorMessage = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed to SignalR clients when an agent's task progress updates.
|
||||
/// Matches the TypeScript <c>TaskProgressUpdate</c> interface from the design spec.
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier.</param>
|
||||
/// <param name="TaskDescription">Description of the current task.</param>
|
||||
/// <param name="Progress">Task progress percentage (0–100), if trackable.</param>
|
||||
/// <param name="Elapsed">Elapsed time string, e.g. "04m 12s".</param>
|
||||
public record TaskProgressUpdate(
|
||||
string AgentId,
|
||||
string TaskDescription,
|
||||
int? Progress,
|
||||
string? Elapsed
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Data transfer object for broadcasting agent status updates
|
||||
/// to all connected SignalR clients via the hub's SendStatusUpdate method.
|
||||
///
|
||||
/// <para>This DTO provides a mutable, serialization-friendly alternative to
|
||||
/// <see cref="AgentStatusUpdate"/> for callers that construct updates
|
||||
/// from external data sources (e.g., HTTP API payloads).</para>
|
||||
/// </summary>
|
||||
public class AgentStatusUpdateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent identifier, e.g. "otto", "dex", "rex".
|
||||
/// </summary>
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name, e.g. "Otto", "Dex".
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
|
||||
/// </summary>
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Current operational status of the agent as lowercase string:
|
||||
/// "active", "idle", "thinking", "error".
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the agent's current task, if any.
|
||||
/// </summary>
|
||||
public string? CurrentTask { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||
/// </summary>
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Communication channel, e.g. "telegram", "discord", "slack".
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 timestamp of the agent's last activity.
|
||||
/// </summary>
|
||||
public string LastActivity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Error message when the agent status is "error".
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this DTO to an immutable <see cref="AgentStatusUpdate"/> record
|
||||
/// for use with the typed SignalR client interface.
|
||||
/// </summary>
|
||||
/// <returns>An <see cref="AgentStatusUpdate"/> with equivalent field values.</returns>
|
||||
public AgentStatusUpdate ToUpdate() => new(
|
||||
AgentId,
|
||||
DisplayName,
|
||||
Role,
|
||||
Status,
|
||||
CurrentTask,
|
||||
SessionKey,
|
||||
Channel,
|
||||
LastActivity,
|
||||
ErrorMessage
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of an agent's full card data, sent on initial connection
|
||||
/// or when the fleet state is requested.
|
||||
/// Matches the TypeScript <c>AgentCardData</c> interface from the design spec.
|
||||
/// </summary>
|
||||
public record AgentCardData(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
string Status,
|
||||
string? CurrentTask,
|
||||
int? TaskProgress,
|
||||
string? TaskElapsed,
|
||||
string SessionKey,
|
||||
string Channel,
|
||||
string LastActivity,
|
||||
string? ErrorMessage
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ControlCenter.Hubs;
|
||||
using ControlCenter.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── API Services ───────────────────────────────────────────
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "Control Center API",
|
||||
Version = "v1",
|
||||
Description = "OpenClaw Control Center — Command Hub backend with SignalR real-time updates"
|
||||
});
|
||||
|
||||
// Include XML doc comments in Swagger output
|
||||
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
c.IncludeXmlComments(xmlPath);
|
||||
}
|
||||
});
|
||||
|
||||
// ── CORS (kiosk + remote browser) ─────────────────────────
|
||||
// The Control Center frontend runs on a different origin than the backend.
|
||||
// SignalR requires credentials for WebSocket transport, so we use
|
||||
// specific origins rather than AllowAnyOrigin.
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins")
|
||||
.Get<string[]>() ?? new[] { "http://localhost:4200", "http://localhost:5000" };
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(corsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials(); // Required for SignalR WebSocket
|
||||
});
|
||||
});
|
||||
|
||||
// ── SignalR (real-time agent status updates) ───────────────
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// ── Gateway Bridge Service ────────────────────────────────
|
||||
// Background service that connects to the OpenClaw Gateway WebSocket
|
||||
// and bridges events to the AgentStatus SignalR hub.
|
||||
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 ──────────────────────────────────────────────
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// ── Hub Endpoints ───────────────────────────────────────────
|
||||
// Agent status hub at /hubs/agent-status (matches the design spec)
|
||||
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||
|
||||
app.Run();
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5053",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ControlCenter.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that connects to the OpenClaw Gateway WebSocket
|
||||
/// and bridges Gateway events to the <see cref="Hubs.AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>Architecture:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Connects to the Gateway WS endpoint (configurable via appsettings)</item>
|
||||
/// <item>Handles the v3 protocol handshake (challenge → connect → hello-ok)</item>
|
||||
/// <item>Subscribes to <c>sessions.changed</c> and related events</item>
|
||||
/// <item>Translates session state changes into <see cref="AgentStatusUpdate"/>
|
||||
/// and <see cref="TaskProgressUpdate"/> objects</item>
|
||||
/// <item>Pushes updates through the <see cref="Hubs.AgentStatusHub"/> SignalR hub</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>This is the server-side bridge that allows Angular clients to
|
||||
/// receive real-time updates via SignalR instead of connecting directly
|
||||
/// to the Gateway WebSocket.</para>
|
||||
/// </summary>
|
||||
public class GatewayEventBridgeService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<GatewayEventBridgeService> _logger;
|
||||
private readonly IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> _hubContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory fleet state — maps agent IDs to their latest card data.
|
||||
/// Updated on every <c>sessions.changed</c> event from the Gateway.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, AgentCardData> _fleetState = new();
|
||||
|
||||
/// <summary>
|
||||
/// Known agent roles for display in the Command Hub.
|
||||
/// Maps agent IDs to their functional descriptions.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> AgentRoles = new()
|
||||
{
|
||||
["main"] = "Primary Assistant",
|
||||
["otto"] = "Orchestrator Agent",
|
||||
["dave"] = "Network Admin Agent",
|
||||
["bob"] = "Content Writer Agent",
|
||||
["stuart"] = "Image & Creative Agent",
|
||||
["phil"] = "Home Automation Agent",
|
||||
["carl"] = "Security Agent",
|
||||
["larry"] = "Business Agent",
|
||||
["mel"] = "E-Commerce Agent",
|
||||
["norbert"] = "Product Agent",
|
||||
["jerry"] = "Market Research Agent",
|
||||
["rex"] = "Frontend Dev Agent",
|
||||
["dex"] = "Backend Dev Agent",
|
||||
["hex"] = "Database Agent",
|
||||
["pip"] = "Raspberry Pi Agent",
|
||||
["nano"] = "ESP32/Firmware Agent",
|
||||
["axiom"] = "Utility Agent",
|
||||
["bonnie"] = "Music Agent",
|
||||
["sketch"] = "UI/UX Design Agent",
|
||||
["flip"] = "Mobile Dev Agent",
|
||||
["buzz"] = "SEO Agent",
|
||||
["aries"] = "Companion Agent"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenClaw session status to <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
private static string MapSessionStatus(string? sessionStatus) => sessionStatus switch
|
||||
{
|
||||
"running" => "active",
|
||||
"streaming" => "thinking",
|
||||
"error" or "aborted" => "error",
|
||||
"done" => "idle",
|
||||
_ => "idle"
|
||||
};
|
||||
|
||||
public GatewayEventBridgeService(
|
||||
ILogger<GatewayEventBridgeService> logger,
|
||||
IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> hubContext,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_hubContext = hubContext;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current fleet state snapshot.
|
||||
/// Used by the hub's <c>GetFleetSnapshot</c> method and by the
|
||||
/// <c>AgentsController</c> REST endpoint.
|
||||
/// </summary>
|
||||
public AgentCardData[] GetFleetSnapshot() =>
|
||||
_fleetState.Values.ToArray();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Gateway Event Bridge service starting");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndListenAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Gateway Event Bridge service stopping");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Gateway connection lost, reconnecting in 5 seconds...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the OpenClaw Gateway WebSocket and processes events
|
||||
/// until the connection is lost or cancellation is requested.
|
||||
/// </summary>
|
||||
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var gatewayUrl = _configuration["Gateway:WebSocketUrl"]
|
||||
?? "ws://localhost:3271/ws";
|
||||
var authToken = _configuration["Gateway:AuthToken"] ?? string.Empty;
|
||||
|
||||
_logger.LogInformation("Connecting to Gateway at {Url}", gatewayUrl);
|
||||
|
||||
using var ws = new ClientWebSocket();
|
||||
|
||||
// Set auth header if available
|
||||
if (!string.IsNullOrEmpty(authToken))
|
||||
{
|
||||
ws.Options.SetRequestHeader("Authorization", $"Bearer {authToken}");
|
||||
}
|
||||
|
||||
await ws.ConnectAsync(new Uri(gatewayUrl), stoppingToken);
|
||||
_logger.LogInformation("Connected to Gateway WebSocket");
|
||||
|
||||
// Start receiving messages
|
||||
await ReceiveMessagesAsync(ws, stoppingToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives and processes WebSocket messages from the Gateway.
|
||||
/// Handles the v3 protocol handshake and dispatches events.
|
||||
/// </summary>
|
||||
private async Task ReceiveMessagesAsync(ClientWebSocket ws, CancellationToken stoppingToken)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var messageBuilder = new StringBuilder();
|
||||
|
||||
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
WebSocketReceiveResult result;
|
||||
try
|
||||
{
|
||||
result = await ws.ReceiveAsync(buffer, stoppingToken);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket receive error");
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogInformation("Gateway WebSocket closed by server");
|
||||
break;
|
||||
}
|
||||
|
||||
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
var message = messageBuilder.ToString();
|
||||
messageBuilder.Clear();
|
||||
await ProcessMessageAsync(ws, message, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single WebSocket message from the Gateway.
|
||||
/// Routes based on the message type: event, response, or challenge.
|
||||
/// </summary>
|
||||
private async Task ProcessMessageAsync(
|
||||
ClientWebSocket ws, string message, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var type = root.GetProperty("type").GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "event":
|
||||
await HandleGatewayEventAsync(root);
|
||||
break;
|
||||
|
||||
case "res":
|
||||
HandleGatewayResponse(root);
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for connect.challenge events
|
||||
if (root.TryGetProperty("event", out var eventName) &&
|
||||
eventName.GetString() == "connect.challenge")
|
||||
{
|
||||
await HandleConnectChallengeAsync(ws, root, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Gateway message: {Message}",
|
||||
message.Length > 200 ? message[..200] + "..." : message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Gateway connect.challenge event by sending
|
||||
/// a connect request with authentication credentials.
|
||||
/// </summary>
|
||||
private async Task HandleConnectChallengeAsync(
|
||||
ClientWebSocket ws, JsonElement root, CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Received connect.challenge from Gateway");
|
||||
|
||||
var connectRequest = new
|
||||
{
|
||||
type = "req",
|
||||
id = $"bridge-{Guid.NewGuid():N}",
|
||||
method = "connect",
|
||||
@params = new
|
||||
{
|
||||
minProtocol = 3,
|
||||
maxProtocol = 3,
|
||||
client = new
|
||||
{
|
||||
id = "control-center-backend",
|
||||
version = "1.0.0",
|
||||
platform = "server",
|
||||
mode = "operator"
|
||||
},
|
||||
role = "operator",
|
||||
scopes = new[] { "operator.read", "operator.write" },
|
||||
auth = new
|
||||
{
|
||||
token = _configuration["Gateway:AuthToken"] ?? string.Empty
|
||||
},
|
||||
locale = "en-US",
|
||||
userAgent = "control-center-backend/1.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(connectRequest);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, stoppingToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a Gateway event message by dispatching to the
|
||||
/// appropriate handler based on event name.
|
||||
/// </summary>
|
||||
private async Task HandleGatewayEventAsync(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var eventProp)) return;
|
||||
var eventName = eventProp.GetString();
|
||||
|
||||
_logger.LogDebug("Gateway event: {Event}", eventName);
|
||||
|
||||
switch (eventName)
|
||||
{
|
||||
case "sessions.changed":
|
||||
await HandleSessionsChangedAsync(root);
|
||||
break;
|
||||
|
||||
case "session.message":
|
||||
HandleSessionMessage(root);
|
||||
break;
|
||||
|
||||
case "session.tool":
|
||||
HandleSessionTool(root);
|
||||
break;
|
||||
|
||||
case "health":
|
||||
HandleHealthEvent(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a sessions.changed event from the Gateway.
|
||||
/// Updates the fleet state and pushes status changes through SignalR.
|
||||
/// </summary>
|
||||
private async Task HandleSessionsChangedAsync(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
// The payload may contain a snapshot of all sessions
|
||||
if (payload.TryGetProperty("snapshot", out var snapshot) &&
|
||||
snapshot.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var session in snapshot.EnumerateArray())
|
||||
{
|
||||
var cardData = SessionToCardData(session);
|
||||
if (cardData is null) continue;
|
||||
|
||||
_fleetState[cardData.Id] = cardData;
|
||||
|
||||
var update = new AgentStatusUpdate(
|
||||
AgentId: cardData.Id,
|
||||
DisplayName: cardData.DisplayName,
|
||||
Role: cardData.Role,
|
||||
Status: cardData.Status,
|
||||
CurrentTask: cardData.CurrentTask,
|
||||
SessionKey: cardData.SessionKey,
|
||||
Channel: cardData.Channel,
|
||||
LastActivity: cardData.LastActivity,
|
||||
ErrorMessage: cardData.ErrorMessage
|
||||
);
|
||||
|
||||
await _hubContext.PushAgentStatusAsync(update);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual updates/added/removed
|
||||
if (payload.TryGetProperty("updated", out var updated) &&
|
||||
updated.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var sessionKey in updated.EnumerateArray())
|
||||
{
|
||||
_logger.LogDebug("Session updated: {Key}", sessionKey.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a session.message event. Updates the agent's last activity
|
||||
/// and pushes a status update if the status changed.
|
||||
/// </summary>
|
||||
private void HandleSessionMessage(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||
|
||||
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||
var agentId = ExtractAgentId(sessionKey);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return;
|
||||
|
||||
// Update last activity timestamp
|
||||
if (_fleetState.TryGetValue(agentId, out var existing))
|
||||
{
|
||||
_fleetState[agentId] = existing with
|
||||
{
|
||||
LastActivity = DateTime.UtcNow.ToString("o"),
|
||||
Status = "active"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a session.tool event. Extracts tool progress information
|
||||
/// and pushes a <see cref="TaskProgressUpdate"/> through SignalR.
|
||||
/// </summary>
|
||||
private void HandleSessionTool(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||
|
||||
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||
var agentId = ExtractAgentId(sessionKey);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return;
|
||||
|
||||
var toolName = payload.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
|
||||
var toolStatus = payload.TryGetProperty("status", out var ts) ? ts.GetString() : null;
|
||||
|
||||
if (toolName is null || toolStatus is null) return;
|
||||
|
||||
var progress = toolStatus switch
|
||||
{
|
||||
"started" => 0,
|
||||
"completed" => 100,
|
||||
_ => (int?)null
|
||||
};
|
||||
|
||||
var update = new TaskProgressUpdate(
|
||||
AgentId: agentId,
|
||||
TaskDescription: $"{toolName} ({toolStatus})",
|
||||
Progress: progress,
|
||||
Elapsed: null
|
||||
);
|
||||
|
||||
// Fire and forget — don't block the event loop
|
||||
_ = _hubContext.PushTaskProgressAsync(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a health event from the Gateway.
|
||||
/// Logs the health status for diagnostics.
|
||||
/// </summary>
|
||||
private void HandleHealthEvent(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
var ok = payload.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
|
||||
var status = payload.TryGetProperty("status", out var s) ? s.GetString() : "unknown";
|
||||
|
||||
_logger.LogInformation("Gateway health: ok={Ok}, status={Status}", ok, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a Gateway response message. Currently only logs for diagnostics.
|
||||
/// </summary>
|
||||
private void HandleGatewayResponse(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
_logger.LogDebug("Gateway RPC response OK");
|
||||
|
||||
// Check for hello-ok after connect
|
||||
if (root.TryGetProperty("payload", out var payload) &&
|
||||
payload.TryGetProperty("type", out var typeProp) &&
|
||||
typeProp.GetString() == "hello-ok")
|
||||
{
|
||||
_logger.LogInformation("Gateway handshake complete (hello-ok received)");
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
var errorMsg = error.TryGetProperty("message", out var msg)
|
||||
? msg.GetString() : "unknown error";
|
||||
_logger.LogWarning("Gateway RPC error: {Error}", errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw Gateway session JSON element into an
|
||||
/// <see cref="AgentCardData"/> record.
|
||||
/// </summary>
|
||||
private AgentCardData? SessionToCardData(JsonElement session)
|
||||
{
|
||||
// Extract agent ID from session key or agentId field
|
||||
string? agentId = null;
|
||||
if (session.TryGetProperty("agentId", out var aid))
|
||||
agentId = aid.GetString();
|
||||
else if (session.TryGetProperty("key", out var key))
|
||||
agentId = ExtractAgentId(key.GetString() ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return null;
|
||||
|
||||
var sessionKey = session.TryGetProperty("key", out var sk)
|
||||
? sk.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var status = session.TryGetProperty("status", out var s)
|
||||
? MapSessionStatus(s.GetString()) : "idle";
|
||||
|
||||
var channel = ExtractChannel(session);
|
||||
|
||||
var lastActivity = session.TryGetProperty("updatedAt", out var ua)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(ua.GetInt64()).ToString("o")
|
||||
: DateTime.UtcNow.ToString("o");
|
||||
|
||||
var displayName = char.ToUpperInvariant(agentId![0]) + agentId[1..];
|
||||
var role = AgentRoles.GetValueOrDefault(agentId!, "Agent");
|
||||
|
||||
return new AgentCardData(
|
||||
Id: agentId!,
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
Status: status,
|
||||
CurrentTask: null,
|
||||
TaskProgress: null,
|
||||
TaskElapsed: null,
|
||||
SessionKey: sessionKey,
|
||||
Channel: channel,
|
||||
LastActivity: lastActivity,
|
||||
ErrorMessage: status == "error" ? "Agent encountered an error" : null
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the agent ID from a session key.
|
||||
/// Session key format: "agent:{agentId}:{channel}:..."
|
||||
/// </summary>
|
||||
private static string? ExtractAgentId(string sessionKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionKey)) return null;
|
||||
|
||||
var parts = sessionKey.Split(':');
|
||||
if (parts.Length >= 2 && parts[0] == "agent")
|
||||
return parts[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the channel from a session element.
|
||||
/// </summary>
|
||||
private static string ExtractChannel(JsonElement session)
|
||||
{
|
||||
// Try direct "channel" property
|
||||
if (session.TryGetProperty("channel", out var ch))
|
||||
return ch.GetString() ?? "unknown";
|
||||
|
||||
// Try origin.provider
|
||||
if (session.TryGetProperty("origin", out var origin) &&
|
||||
origin.TryGetProperty("provider", out var provider))
|
||||
return provider.GetString() ?? "unknown";
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"ControlCenter": "Debug"
|
||||
}
|
||||
},
|
||||
"Gateway": {
|
||||
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||
"AuthToken": ""
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:4200",
|
||||
"http://localhost:5000"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"ControlCenter": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Gateway": {
|
||||
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||
"AuthToken": ""
|
||||
},
|
||||
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:4200",
|
||||
"http://localhost:5000"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using ControlCenter.Api.Configurations;
|
||||
using ControlCenter.Api.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ControlCenter.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Control Center database.
|
||||
/// All table and column names use snake_case via explicit HasColumnName configuration.
|
||||
/// </summary>
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Agent> Agents => Set<Agent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Apply all entity type configurations from the Configurations namespace
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AgentConfiguration).Assembly);
|
||||
|
||||
// Map the AgentStatus enum to a PostgreSQL enum type named "agent_status"
|
||||
// This must be called after ApplyConfigurations to ensure the model is built
|
||||
// before the enum mapping is applied.
|
||||
modelBuilder.HasPostgresEnum<AgentStatus>();
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ControlCenter.Api.Entities;
|
||||
|
||||
namespace ControlCenter.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef)
|
||||
/// to create migrations without requiring a running application.
|
||||
/// </summary>
|
||||
public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
|
||||
// Connection string for design-time operations (migrations).
|
||||
// In production, this comes from appsettings / environment variables.
|
||||
var connectionString = "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||
});
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace ControlCenter.Api.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Data transfer object for broadcasting agent status updates
|
||||
/// to all connected SignalR clients.
|
||||
/// </summary>
|
||||
public class AgentStatusUpdateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent identifier, e.g. "otto", "dex", "rex".
|
||||
/// Not null — every update must identify the agent it refers to.
|
||||
/// </summary>
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name, e.g. "Otto", "Dex".
|
||||
/// Not null — used by clients to render agent cards.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
|
||||
/// Not null — provides context for the agent's function.
|
||||
/// </summary>
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Current operational status of the agent.
|
||||
/// Maps to <see cref="Entities.AgentStatus"/> values as lowercase strings:
|
||||
/// "active", "idle", "thinking", "error".
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the agent's current task, if any.
|
||||
/// Null when the agent is idle with no active task.
|
||||
/// </summary>
|
||||
public string? CurrentTask { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Task progress percentage (0–100).
|
||||
/// Null when progress is not trackable for the current task.
|
||||
/// </summary>
|
||||
public int? TaskProgress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Elapsed time string for the current task, e.g. "04m 12s".
|
||||
/// Null when no task is active.
|
||||
/// </summary>
|
||||
public string? TaskElapsed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||
/// Not null — uniquely identifies the agent session.
|
||||
/// </summary>
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||
/// Not null — every agent session operates on exactly one channel.
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 timestamp of the agent's last activity.
|
||||
/// Not null — used by clients to detect stale connections.
|
||||
/// </summary>
|
||||
public string LastActivity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Error message when the agent status is "error".
|
||||
/// Null when the agent is not in an error state.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
namespace ControlCenter.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent's current state in the Control Center.
|
||||
/// Each row tracks one agent session's status, task, and activity.
|
||||
/// </summary>
|
||||
public class Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key — UUID generated on insert.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current operational status of the agent.
|
||||
/// Stored as an enum in PostgreSQL via Npgsql.
|
||||
/// </summary>
|
||||
public AgentStatus Status { get; set; } = AgentStatus.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the agent's current task, if any.
|
||||
/// Nullable — not all agents have an active task.
|
||||
/// </summary>
|
||||
public string? Task { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Task progress percentage (0–100).
|
||||
/// Nullable — progress is only meaningful when an agent has a trackable task.
|
||||
/// </summary>
|
||||
public int? Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||
/// Not null — every agent row must be associated with a session.
|
||||
/// </summary>
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||
/// Not null — every agent session operates on exactly one channel.
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the agent's last activity.
|
||||
/// Default: current UTC timestamp on insert.
|
||||
/// </summary>
|
||||
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Row creation timestamp. Set automatically on insert.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Row last-update timestamp. Updated automatically on any modification.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ControlCenter.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Agent operational status enum.
|
||||
/// Maps to the agent_status enum type in PostgreSQL.
|
||||
/// </summary>
|
||||
public enum AgentStatus
|
||||
{
|
||||
Active = 0,
|
||||
Idle = 1,
|
||||
Thinking = 2,
|
||||
Error = 3
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using ControlCenter.Api.Dtos;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Api.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for broadcasting agent status updates to connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Clients call <see cref="SendStatusUpdate"/> to broadcast a status change,
|
||||
/// and the hub relays it to all connected clients via the
|
||||
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Server-side code should use <see cref="AgentStatusHubExtensions.PushStatusUpdateAsync"/>
|
||||
/// via <c>IHubContext<AgentStatusHub, IAgentStatusClient></c> for background-service broadcasts.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Architecture note: This hub bridges OpenClaw Gateway events to SignalR clients.
|
||||
/// A background service subscribes to Gateway events and pushes them through
|
||||
/// this hub's extension methods.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||
{
|
||||
private readonly ILogger<AgentStatusHub> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStatusHub"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger for diagnostic output.</param>
|
||||
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an agent status update to all connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Any connected client (or server-side caller) can invoke this method
|
||||
/// to push a status update to every subscriber. The update is relayed
|
||||
/// through the <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="update">The agent status update payload to broadcast.</param>
|
||||
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Broadcasting status update for agent {AgentId}: {Status}",
|
||||
update.AgentId, update.Status);
|
||||
|
||||
await Clients.All.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to the fleet group.
|
||||
/// Once joined, the client will receive all agent status updates.
|
||||
/// </summary>
|
||||
public async Task JoinFleet()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from the fleet group.
|
||||
/// </summary>
|
||||
public async Task LeaveFleet()
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides <see cref="Hub{T}.OnDisconnectedAsync"/> to log disconnections.
|
||||
/// SignalR automatically removes disconnected connections from all groups.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR group name for the entire fleet (all agents).
|
||||
/// </summary>
|
||||
internal const string FleetGroupName = "fleet";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||
/// Defines the methods the server can invoke on connected clients
|
||||
/// to push real-time agent status updates.
|
||||
/// </summary>
|
||||
public interface IAgentStatusClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all subscribed clients.
|
||||
/// Fired whenever an agent's operational status changes
|
||||
/// (e.g., idle → active, active → thinking, active → error).
|
||||
/// </summary>
|
||||
/// <param name="update">The full status update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentStatusChanged(AgentStatusUpdateDto update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for pushing real-time agent updates through
|
||||
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// These methods are intended to be called from background services
|
||||
/// or other server-side code that detects an agent state change,
|
||||
/// using the injected <c>IHubContext<AgentStatusHub, IAgentStatusClient></c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentStatusHubExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status update to all connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Call this from any background service when an agent's
|
||||
/// operational status changes (e.g., the Gateway reports a
|
||||
/// session transition from "running" to "done").
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all clients.</returns>
|
||||
public static async Task PushStatusUpdateAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdateDto update)
|
||||
{
|
||||
await hubContext.Clients.All.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an agent status update to clients subscribed to the fleet group.
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to the fleet group.</returns>
|
||||
public static async Task PushStatusUpdateToFleetAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdateDto update)
|
||||
{
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentStatusChanged(update);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ControlCenter.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260426101703_CreateAgentsTable")]
|
||||
partial class CreateAgentsTable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTime>("LastActivity")
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("last_activity");
|
||||
|
||||
b.Property<int?>("Progress")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("progress");
|
||||
|
||||
b.Property<string>("SessionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_key");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("agent_status")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Task")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("task");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel")
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
b.HasIndex("SessionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_agents_session_key");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
b.ToTable("agents", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CreateAgentsTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:Enum:agent_status", "active,idle,thinking,error");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "agents",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
status = table.Column<int>(type: "agent_status", nullable: false),
|
||||
task = table.Column<string>(type: "text", nullable: true),
|
||||
progress = table.Column<int>(type: "integer", nullable: true),
|
||||
session_key = table.Column<string>(type: "text", nullable: false),
|
||||
channel = table.Column<string>(type: "text", nullable: false),
|
||||
last_activity = table.Column<DateTime>(type: "timestamptz", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_agents", x => x.id);
|
||||
table.CheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_channel",
|
||||
table: "agents",
|
||||
column: "channel");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_session_key",
|
||||
table: "agents",
|
||||
column: "session_key",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_status",
|
||||
table: "agents",
|
||||
column: "status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "agents");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ControlCenter.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTime>("LastActivity")
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("last_activity");
|
||||
|
||||
b.Property<int?>("Progress")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("progress");
|
||||
|
||||
b.Property<string>("SessionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_key");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("agent_status")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Task")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("task");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel")
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
b.HasIndex("SessionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_agents_session_key");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
b.ToTable("agents", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace ControlCenter.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only model representing an agent's current state.
|
||||
/// Used as the return type from the Agent State Repository
|
||||
/// to decouple consumers from the persistence layer.
|
||||
/// </summary>
|
||||
public class AgentState
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public string? Task { get; set; }
|
||||
public int? Progress { get; set; }
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
public DateTime LastActivity { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using ControlCenter.Api.Data;
|
||||
using ControlCenter.Api.Hubs;
|
||||
using ControlCenter.Api.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Register SignalR for real-time agent status updates
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Register Agent State Repository
|
||||
builder.Services.AddScoped<IAgentStateRepository, AgentStateRepository>();
|
||||
|
||||
// Register DbContext with PostgreSQL
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||
|
||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||
|
||||
app.Run();
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5178",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7041;http://localhost:5178",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using ControlCenter.Api.Data;
|
||||
using ControlCenter.Api.Entities;
|
||||
using ControlCenter.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ControlCenter.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of the Agent State Repository.
|
||||
/// Maps between the persisted <see cref="Agent"/> entity and the
|
||||
/// read-oriented <see cref="AgentState"/> model.
|
||||
/// </summary>
|
||||
public class AgentStateRepository : IAgentStateRepository
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public AgentStateRepository(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
var agents = await _db.Agents
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(a => a.LastActivity)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return agents.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AgentState?> GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default)
|
||||
{
|
||||
var agent = await _db.Agents
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.SessionKey == sessionKey, ct);
|
||||
|
||||
return agent is null ? null : ToModel(agent);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||
{
|
||||
if (!Enum.TryParse<AgentStatus>(status, ignoreCase: true, out var parsedStatus))
|
||||
return false;
|
||||
|
||||
var agent = await _db.Agents.FindAsync([id], ct);
|
||||
if (agent is null)
|
||||
return false;
|
||||
|
||||
agent.Status = parsedStatus;
|
||||
agent.UpdatedAt = DateTime.UtcNow;
|
||||
agent.LastActivity = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a persisted <see cref="Agent"/> entity to a <see cref="AgentState"/> model.
|
||||
/// </summary>
|
||||
private static AgentState ToModel(Agent agent) => new()
|
||||
{
|
||||
Id = agent.Id,
|
||||
Status = agent.Status.ToString(),
|
||||
Task = agent.Task,
|
||||
Progress = agent.Progress,
|
||||
SessionKey = agent.SessionKey,
|
||||
Channel = agent.Channel,
|
||||
LastActivity = agent.LastActivity,
|
||||
CreatedAt = agent.CreatedAt,
|
||||
UpdatedAt = agent.UpdatedAt,
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using ControlCenter.Api.Models;
|
||||
|
||||
namespace ControlCenter.Api.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for accessing and mutating Agent State.
|
||||
/// Provides a clean abstraction over the EF Core data access layer.
|
||||
/// </summary>
|
||||
public interface IAgentStateRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve all agent states.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AgentState>> GetAllAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a single agent state by its session key.
|
||||
/// Returns null if no agent is found with the given session key.
|
||||
/// </summary>
|
||||
Task<AgentState?> GetBySessionKeyAsync(string sessionKey, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update the status of an agent by its primary key.
|
||||
/// Returns true if the agent was found and updated, false otherwise.
|
||||
/// </summary>
|
||||
Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres"
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Package models defines the database entities for the Control Center Go backend.
|
||||
// Structs map 1:1 to the PostgreSQL schema defined in backend/migrations/.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// AgentStatus represents the possible lifecycle states of an agent.
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
AgentStatusActive AgentStatus = "active"
|
||||
AgentStatusIdle AgentStatus = "idle"
|
||||
AgentStatusThinking AgentStatus = "thinking"
|
||||
AgentStatusError AgentStatus = "error"
|
||||
AgentStatusOffline AgentStatus = "offline"
|
||||
)
|
||||
|
||||
// Agent represents a registered agent and its current state.
|
||||
type Agent struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Status AgentStatus `db:"status" json:"status"`
|
||||
Task *string `db:"task" json:"task,omitempty"`
|
||||
Progress int32 `db:"progress" json:"progress"`
|
||||
SessionKey *string `db:"session_key" json:"session_key,omitempty"`
|
||||
Channel *string `db:"channel" json:"channel,omitempty"`
|
||||
LastActivity time.Time `db:"last_activity" json:"last_activity"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionStatus represents the possible states of an agent session.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusRunning SessionStatus = "running"
|
||||
SessionStatusCompleted SessionStatus = "completed"
|
||||
SessionStatusCrashed SessionStatus = "crashed"
|
||||
SessionStatusTerminated SessionStatus = "terminated"
|
||||
)
|
||||
|
||||
// Session tracks an agent session over time.
|
||||
type Session struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"`
|
||||
Status SessionStatus `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
// TaskLogStatus represents the possible states of a task log entry.
|
||||
type TaskLogStatus string
|
||||
|
||||
const (
|
||||
TaskLogStatusPending TaskLogStatus = "pending"
|
||||
TaskLogStatusRunning TaskLogStatus = "running"
|
||||
TaskLogStatusCompleted TaskLogStatus = "completed"
|
||||
TaskLogStatusFailed TaskLogStatus = "failed"
|
||||
TaskLogStatusCancelled TaskLogStatus = "cancelled"
|
||||
)
|
||||
|
||||
// TaskLog records a historical task assigned to an agent.
|
||||
type TaskLog struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
Task string `db:"task" json:"task"`
|
||||
Status TaskLogStatus `db:"status" json:"status"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectStatus represents the possible states of a project.
|
||||
type ProjectStatus string
|
||||
|
||||
const (
|
||||
ProjectStatusPlanned ProjectStatus = "planned"
|
||||
ProjectStatusInProgress ProjectStatus = "in_progress"
|
||||
ProjectStatusCompleted ProjectStatus = "completed"
|
||||
ProjectStatusPaused ProjectStatus = "paused"
|
||||
ProjectStatusCancelled ProjectStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Project represents a project managed by the Control Center.
|
||||
type Project struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Status ProjectStatus `db:"status" json:"status"`
|
||||
AgentID *pgtype.UUID `db:"agent_id" json:"agent_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentEvent represents an event in the agent lifecycle or telemetry stream.
|
||||
type AgentEvent struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
Payload *map[string]interface{} `db:"payload" json:"payload,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
88
docker-compose.yml
Normal file
88
docker-compose.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
# =============================================================================
|
||||
# Control Center — Full-Stack Docker Compose
|
||||
# =============================================================================
|
||||
# Boot the entire stack: PostgreSQL + Go backend + React frontend.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # fill in values
|
||||
# docker compose up --build # first run (builds images)
|
||||
# docker compose up # subsequent runs
|
||||
# docker compose down -v # teardown including DB volume
|
||||
# =============================================================================
|
||||
|
||||
name: control-center
|
||||
|
||||
services:
|
||||
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cc-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-controlcenter}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-controlcenter}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./go-backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-controlcenter} -d ${POSTGRES_DB:-controlcenter}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── Go Backend ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./go-backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-controlcenter}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-controlcenter}?sslmode=disable
|
||||
CORS_ORIGIN: "${CORS_ORIGIN:-http://localhost:3000}"
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
GATEWAY_URL: ${GATEWAY_URL:-http://openclaw-gateway:18789/api/agents}
|
||||
GATEWAY_POLL_INTERVAL: ${GATEWAY_POLL_INTERVAL:-5s}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── React Frontend (nginx) ──────────────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
cc-network:
|
||||
driver: bridge
|
||||
@@ -1,33 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
|
||||
# Build output (rebuilt in container)
|
||||
dist/
|
||||
out-tsc/
|
||||
|
||||
# Angular cache
|
||||
.angular/cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-workspace
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
coverage/
|
||||
tmp/
|
||||
*.log
|
||||
@@ -1,17 +0,0 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
58
frontend/.gitignore
vendored
58
frontend/.gitignore
vendored
@@ -1,44 +1,24 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
frontend/.vscode/extensions.json
vendored
4
frontend/.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/.vscode/launch.json
vendored
20
frontend/.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
frontend/.vscode/mcp.json
vendored
9
frontend/.vscode/mcp.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
frontend/.vscode/tasks.json
vendored
42
frontend/.vscode/tasks.json
vendored
@@ -1,42 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# ============================================================
|
||||
# Control Center Frontend — Multi-stage Docker Build
|
||||
# Angular 21 + nginx for static serving + API proxy
|
||||
# React 19 + Vite + nginx for static serving + API proxy
|
||||
# ============================================================
|
||||
|
||||
# --- Build Stage ---
|
||||
@@ -25,8 +25,8 @@ RUN rm /etc/nginx/conf.d/default.conf
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built Angular app from builder stage
|
||||
COPY --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
||||
# Copy built React app from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
@@ -35,4 +35,4 @@ EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,59 +1,73 @@
|
||||
# Frontend
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Development server
|
||||
Currently, two official plugins are available:
|
||||
|
||||
To start a local development server, run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
## Code scaffolding
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src",
|
||||
"src/styles"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,7 @@ server {
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Cache static assets (Angular uses content hashes)
|
||||
# Cache static assets (Vite uses content hashes)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -34,21 +34,8 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy SignalR WebSocket connections to backend
|
||||
location /hubs/ {
|
||||
proxy_pass http://backend:8080/hubs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Angular SPA — all other routes fall back to index.html
|
||||
# React SPA — all other routes fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8124
frontend/package-lock.json
generated
8124
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.11.0",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.10",
|
||||
"@angular/cdk": "^21.2.8",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/material": "^21.2.8",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"axios": "^1.16.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.8",
|
||||
"@angular/cli": "^21.2.8",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import HubPage from './pages/HubPage'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import SessionsPage from './pages/SessionsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HubPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<router-outlet />
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { LayoutShellComponent } from './layout/layout-shell/layout-shell.component';
|
||||
import { HubPageComponent } from './pages/hub/hub-page.component';
|
||||
import { ProjectsPageComponent } from './pages/projects/projects-page.component';
|
||||
import { SessionsPageComponent } from './pages/sessions/sessions-page.component';
|
||||
import { LogsPageComponent } from './pages/logs/logs-page.component';
|
||||
import { SettingsPageComponent } from './pages/settings/settings-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LayoutShellComponent,
|
||||
children: [
|
||||
{ path: '', redirectTo: 'hub', pathMatch: 'full' },
|
||||
{ path: 'hub', component: HubPageComponent },
|
||||
{ path: 'projects', component: ProjectsPageComponent },
|
||||
{ path: 'sessions', component: SessionsPageComponent },
|
||||
{ path: 'logs', component: LogsPageComponent },
|
||||
{ path: 'settings', component: SettingsPageComponent },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class App {}
|
||||
@@ -1,100 +0,0 @@
|
||||
<!-- ========================================================================== -->
|
||||
<!-- AgentCard — per spec Section 7.3 -->
|
||||
<!-- Integrates: Status Badge · Task Progress Bar · Quick‑Jump Button -->
|
||||
<!-- Left‑border accent matches status color. role="article" + aria‑labels. -->
|
||||
<!-- Enhanced: data-status attribute, elapsed time, design tokens. -->
|
||||
<!-- ========================================================================== -->
|
||||
<article
|
||||
class="agent-card"
|
||||
role="article"
|
||||
[attr.data-status]="status"
|
||||
[attr.aria-label]="(displayName || agentId) + ' — ' + statusLabel()"
|
||||
[style.border-left-color]="statusBorderColor()"
|
||||
(click)="cardClick.emit(sessionKey)"
|
||||
appLongPress
|
||||
[appLongPressDuration]="500"
|
||||
(appLongPress)="cardLongPress.emit(sessionKey)"
|
||||
tabindex="0"
|
||||
>
|
||||
|
||||
<!-- ── Header: status badge + agent info ── -->
|
||||
<div class="agent-card__header">
|
||||
<div class="agent-card__badge" [attr.aria-label]="'Status: ' + statusLabel()">
|
||||
<span
|
||||
class="status-dot"
|
||||
[ngClass]="[statusDotClass()]"
|
||||
></span>
|
||||
<span class="agent-card__status-label">{{ statusLabel() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="agent-card__identity">
|
||||
<span class="agent-card__name">{{ displayName || agentId }}</span>
|
||||
<span class="agent-card__role">{{ role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Body: current task ── -->
|
||||
<div class="agent-card__body" *ngIf="task || isError()">
|
||||
<p
|
||||
class="agent-card__task"
|
||||
[class.agent-card__task--error]="isError()"
|
||||
[attr.aria-label]="'Current task: ' + (isError() ? errorMessage || task : task)"
|
||||
>
|
||||
{{ isError() ? errorMessage || task : task }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Task Progress Bar ── -->
|
||||
<div
|
||||
class="agent-card__progress"
|
||||
*ngIf="showProgress()"
|
||||
[attr.aria-label]="'Task progress: ' + progress + '%'"
|
||||
>
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="progress"
|
||||
[aria-label]="'Progress ' + progress + '% complete'"
|
||||
></mat-progress-bar>
|
||||
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Elapsed Time ── -->
|
||||
<div
|
||||
class="agent-card__elapsed"
|
||||
*ngIf="taskElapsed && isActiveLike()"
|
||||
[attr.aria-label]="'Elapsed: ' + taskElapsed"
|
||||
>
|
||||
<mat-icon aria-hidden="true" class="agent-card__elapsed-icon">schedule</mat-icon>
|
||||
<span class="text-mono">{{ taskElapsed }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer: channel + last activity + quick‑jump ── -->
|
||||
<div class="agent-card__footer">
|
||||
<div class="agent-card__meta">
|
||||
<span
|
||||
class="agent-card__channel text-mono"
|
||||
[attr.aria-label]="'Channel: ' + channel"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{ channelIcon() }}</mat-icon>
|
||||
{{ channel }}
|
||||
</span>
|
||||
<span
|
||||
class="agent-card__last-activity text-mono"
|
||||
[attr.aria-label]="'Last activity: ' + lastActivityLabel()"
|
||||
>
|
||||
{{ lastActivityLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick‑Jump Button -->
|
||||
<a
|
||||
class="agent-card__jump"
|
||||
mat-button
|
||||
[routerLink]="jumpRoute()"
|
||||
[attr.aria-label]="'Jump to session for ' + (displayName || agentId)"
|
||||
matTooltip="Jump to session"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,267 +0,0 @@
|
||||
// ============================================================================
|
||||
// AgentCard — M3 tactical dark styling
|
||||
// Per spec Section 7.3: left‑border accent, status‑aware coloring,
|
||||
// responsive card layout with 320px min‑width.
|
||||
// Enhanced: data-status selectors, elapsed time, design token imports.
|
||||
// ============================================================================
|
||||
|
||||
@use 'tokens' as tokens;
|
||||
|
||||
.agent-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: tokens.$cc-card-min-width;
|
||||
padding: tokens.$cc-card-padding;
|
||||
background-color: var(--cc-surface-container);
|
||||
border-radius: tokens.$cc-card-border-radius;
|
||||
border-left: 4px solid var(--status-offline); // default; overridden by [style]
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
gap: 16px;
|
||||
transition: border-left-color 0.3s ease, box-shadow 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// CUB-26: Card is now clickable for session drawer
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Header ──
|
||||
.agent-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-card__badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--status-active-bg); // overridden per status below
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
.agent-card__status-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
|
||||
.agent-card__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.agent-card__name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.agent-card__role {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
|
||||
// ── Body ──
|
||||
.agent-card__body {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.agent-card__task {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface);
|
||||
line-height: 1.4;
|
||||
|
||||
&--error {
|
||||
color: var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress Bar ──
|
||||
.agent-card__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.agent-card__progress-label {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
// Override mat-progress-bar to match tactical dark theme
|
||||
.agent-card__progress ::ng-deep .mat-mdc-progress-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
|
||||
.mdc-linear-progress__bar-inner {
|
||||
background-color: var(--status-active);
|
||||
}
|
||||
|
||||
.mdc-linear-progress__track {
|
||||
background-color: var(--cc-outline);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Elapsed Time ──
|
||||
.agent-card__elapsed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.agent-card__elapsed-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--status-thinking);
|
||||
}
|
||||
|
||||
// ── Footer ──
|
||||
.agent-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: auto; // push footer to bottom
|
||||
}
|
||||
|
||||
.agent-card__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.agent-card__channel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
|
||||
.agent-card__channel .mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.agent-card__last-activity {
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
|
||||
// ── Quick‑Jump Button ──
|
||||
.agent-card__jump {
|
||||
flex-shrink: 0;
|
||||
|
||||
.mat-mdc-button {
|
||||
min-width: 36px;
|
||||
padding: 0 8px;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status‑specific background tints for badge ──
|
||||
// Using data-status attribute selectors for clean styling.
|
||||
|
||||
.agent-card[data-status="active"] .agent-card__badge {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
|
||||
.agent-card[data-status="idle"] .agent-card__badge {
|
||||
background-color: var(--status-idle-bg);
|
||||
}
|
||||
|
||||
.agent-card[data-status="thinking"] .agent-card__badge {
|
||||
background-color: var(--status-thinking-bg);
|
||||
}
|
||||
|
||||
.agent-card[data-status="error"] .agent-card__badge {
|
||||
background-color: var(--status-error-bg);
|
||||
}
|
||||
|
||||
.agent-card[data-status="offline"] .agent-card__badge {
|
||||
background-color: var(--cc-surface-container-high);
|
||||
}
|
||||
|
||||
// ── Active‑like pulse on card border ──
|
||||
.agent-card[data-status="active"],
|
||||
.agent-card[data-status="thinking"] {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.agent-card[data-status="error"] {
|
||||
border-left-color: var(--status-error);
|
||||
}
|
||||
|
||||
// ── Responsive ──
|
||||
@media (max-width: tokens.$cc-breakpoint-mobile) {
|
||||
.agent-card {
|
||||
min-width: unset;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agent-card__header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-card__footer {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.agent-card__meta {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accessibility: reduced motion ──
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { AgentStatus } from '../../../models/agent.model';
|
||||
import { LongPressDirective } from '../../../directives/long-press.directive';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
CHANNEL_ICONS,
|
||||
} from '../../../design/tokens';
|
||||
|
||||
// ============================================================================
|
||||
// AgentCard Component
|
||||
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
|
||||
// and Quick‑Jump Button into a card with left‑border status accent.
|
||||
// CUB-26: Emits cardClick and cardLongPress for drawer/modal integration.
|
||||
// Enhanced with data-status attribute, elapsed time, and design tokens.
|
||||
// ============================================================================
|
||||
|
||||
@Component({
|
||||
selector: 'app-agent-card',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatProgressBarModule,
|
||||
MatTooltipModule,
|
||||
LongPressDirective,
|
||||
],
|
||||
templateUrl: './agent-card.component.html',
|
||||
styleUrl: './agent-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AgentCardComponent implements OnDestroy {
|
||||
// --- Six required inputs per spec ---
|
||||
|
||||
/** Agent status — drives badge color and left‑border accent */
|
||||
@Input({ required: true }) status!: AgentStatus;
|
||||
|
||||
/** Current task description, e.g. "Reviewing PR #42" */
|
||||
@Input() task = '';
|
||||
|
||||
/** Task progress percentage 0–100 */
|
||||
@Input() progress = 0;
|
||||
|
||||
/** Full session key for quick‑jump navigation */
|
||||
@Input({ required: true }) sessionKey = '';
|
||||
|
||||
/** Communication channel, e.g. "telegram" */
|
||||
@Input({ required: true }) channel = '';
|
||||
|
||||
/** Timestamp of last agent activity */
|
||||
@Input({ required: true }) lastActivity!: Date;
|
||||
|
||||
// --- Additional display inputs ---
|
||||
|
||||
/** Short agent ID, e.g. "otto" */
|
||||
@Input() agentId = '';
|
||||
|
||||
/** Display name, e.g. "Otto" */
|
||||
@Input() displayName = '';
|
||||
|
||||
/** Role description, e.g. "Orchestrator Agent" */
|
||||
@Input() role = '';
|
||||
|
||||
/** Error message (shown only when status is 'error') */
|
||||
@Input() errorMessage = '';
|
||||
|
||||
/** Elapsed time string, e.g. "04m 12s" */
|
||||
@Input() taskElapsed = '';
|
||||
|
||||
// --- CUB-26: Outputs for drawer/modal integration ---
|
||||
|
||||
/** Emitted when the card is clicked — opens the session drawer. */
|
||||
@Output() readonly cardClick = new EventEmitter<string>();
|
||||
|
||||
/** Emitted when the card is long-pressed — bypasses drawer, opens session log directly. */
|
||||
@Output() readonly cardLongPress = new EventEmitter<string>();
|
||||
|
||||
// --- Internal state ---
|
||||
|
||||
/** Timer for refreshing relative-time label */
|
||||
private _timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Internal signal to trigger relative-time recomputation */
|
||||
private readonly _tick = signal(0);
|
||||
|
||||
// --- Computed values using design tokens ---
|
||||
|
||||
/** Map status → CSS custom property for the left‑border accent */
|
||||
readonly statusBorderColor = computed(() => {
|
||||
const map: Record<AgentStatus, string> = {
|
||||
active: 'var(--status-active)',
|
||||
idle: 'var(--status-idle)',
|
||||
thinking: 'var(--status-thinking)',
|
||||
error: 'var(--status-error)',
|
||||
offline: 'var(--status-offline)',
|
||||
};
|
||||
return map[this.status] ?? 'var(--status-offline)';
|
||||
});
|
||||
|
||||
/** Human‑readable status label (from design tokens) */
|
||||
readonly statusLabel = computed(() => STATUS_LABELS[this.status] ?? this.status);
|
||||
|
||||
/** CSS class suffix for the status badge dot */
|
||||
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
|
||||
|
||||
/** Material icon name for the channel (from design tokens) */
|
||||
readonly channelIcon = computed(() => CHANNEL_ICONS[this.channel] ?? 'chat');
|
||||
|
||||
/** Relative time string for lastActivity, refreshed every 30s */
|
||||
readonly lastActivityLabel = computed(() => {
|
||||
// Read tick to create dependency that forces recomputation
|
||||
this._tick();
|
||||
return this._relativeTime(this.lastActivity);
|
||||
});
|
||||
|
||||
/** Quick‑jump route derived from sessionKey */
|
||||
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
|
||||
|
||||
/** Whether progress bar should show */
|
||||
readonly showProgress = computed(() => this.progress > 0 && this.status !== 'error');
|
||||
|
||||
/** Whether error state is active */
|
||||
readonly isError = computed(() => this.status === 'error');
|
||||
|
||||
/** Whether card is in an active-like state (active or thinking) */
|
||||
readonly isActiveLike = computed(() => this.status === 'active' || this.status === 'thinking');
|
||||
|
||||
constructor() {
|
||||
// Start the relative-time refresh timer
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this._stopTimer();
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private _relativeTime(date: Date | null | undefined): string {
|
||||
if (!date) return '';
|
||||
const now = Date.now();
|
||||
const then = date.getTime();
|
||||
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||
}
|
||||
|
||||
private _startTimer(): void {
|
||||
this._stopTimer();
|
||||
this._timer = setInterval(() => {
|
||||
// Increment tick to force lastActivityLabel recomputation
|
||||
this._tick.update(v => v + 1);
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
private _stopTimer(): void {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './agent-card/agent-card.component';
|
||||
@@ -1,112 +0,0 @@
|
||||
<!-- ======================================================================== -->
|
||||
<!-- Adaptive Navigation — Desktop sidebar / Mobile header -->
|
||||
<!-- Desktop (≥768px): 72px sidebar with full navigation items -->
|
||||
<!-- Mobile (<768px): 56px compact header with hamburger menu -->
|
||||
<!-- ======================================================================== -->
|
||||
|
||||
<!-- ======================= DESKTOP SIDEBAR (≥768px) ======================= -->
|
||||
<aside class="adaptive-nav__sidebar" aria-label="Navigation sidebar">
|
||||
<!-- Brand / Toggle header -->
|
||||
<div class="adaptive-nav__sidebar-header">
|
||||
<span class="adaptive-nav__brand">OC</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation destinations -->
|
||||
<nav class="adaptive-nav__sidebar-nav">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="adaptive-nav__sidebar-item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="adaptive-nav__sidebar-item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
<span class="adaptive-nav__sidebar-label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar footer: LIVE indicator + action buttons -->
|
||||
<div class="adaptive-nav__sidebar-footer">
|
||||
<div class="adaptive-nav__live" [class.adaptive-nav__live--connected]="isConnected()">
|
||||
<span
|
||||
class="adaptive-nav__live-dot"
|
||||
[class.adaptive-nav__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<mat-chip
|
||||
class="adaptive-nav__live-chip"
|
||||
[highlighted]="isConnected()"
|
||||
[disabled]="!isConnected()"
|
||||
>
|
||||
{{ isConnected() ? 'LIVE' : 'OFFLINE' }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (placeholder) -->
|
||||
<div class="adaptive-nav__sidebar-actions">
|
||||
<button mat-icon-button aria-label="Notifications">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button aria-label="Settings">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ======================= MOBILE HEADER (<768px) ======================== -->
|
||||
<header class="adaptive-nav__mobile-header" role="banner">
|
||||
<!-- Hamburger menu button -->
|
||||
<button
|
||||
class="adaptive-nav__hamburger"
|
||||
mat-icon-button
|
||||
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
|
||||
[attr.aria-expanded]="mobileMenuOpen()"
|
||||
(click)="toggleMobileMenu()"
|
||||
>
|
||||
<mat-icon>{{ mobileMenuOpen() ? 'close' : 'menu' }}</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="adaptive-nav__mobile-title">Command Hub</h1>
|
||||
|
||||
<!-- LIVE indicator (always visible on mobile) -->
|
||||
<div class="adaptive-nav__live adaptive-nav__live--mobile" [class.adaptive-nav__live--connected]="isConnected()">
|
||||
<span
|
||||
class="adaptive-nav__live-dot"
|
||||
[class.adaptive-nav__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<span class="adaptive-nav__live-text">{{ isConnected() ? 'LIVE' : 'OFFLINE' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Mobile action buttons (placeholder) -->
|
||||
<button class="adaptive-nav__mobile-action" mat-icon-button aria-label="Notifications">
|
||||
<mat-icon>notifications</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- ======================= MOBILE DRAWER OVERLAY ========================= -->
|
||||
@if (mobileMenuOpen()) {
|
||||
<div class="adaptive-nav__overlay" (click)="closeMobileMenu()" role="presentation"></div>
|
||||
<nav class="adaptive-nav__mobile-drawer" aria-label="Mobile navigation menu">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="adaptive-nav__drawer-item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="adaptive-nav__drawer-item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
(click)="closeMobileMenu()"
|
||||
>
|
||||
<mat-icon>{{ dest.icon }}</mat-icon>
|
||||
<span class="adaptive-nav__drawer-label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
// ============================================================================
|
||||
// Adaptive Navigation — Desktop sidebar / Mobile header
|
||||
// Per CUB-27 spec breakpoints:
|
||||
// Compact (0–599px): Mobile header + hamburger + drawer
|
||||
// Medium (600–1023px): Collapsed sidebar (icon-only, 72px)
|
||||
// Expanded (≥1024px): Full sidebar with labels (72px collapsed, 256px expanded)
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Desktop Sidebar (visible ≥600px)
|
||||
// ---------------------------------------------------------------------------
|
||||
.adaptive-nav__sidebar {
|
||||
display: none; // Hidden by default (mobile-first)
|
||||
flex-direction: column;
|
||||
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.adaptive-nav__brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--status-active);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-nav {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 56px;
|
||||
padding: 8px 0;
|
||||
margin: 2px 8px;
|
||||
border-radius: 28px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
|
||||
.adaptive-nav__sidebar-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-label {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar Footer — LIVE indicator + action buttons
|
||||
// ---------------------------------------------------------------------------
|
||||
.adaptive-nav__sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0 20px;
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.mat-mdc-icon-button {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
--mdc-icon-button-icon-size: 20px;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIVE Status Indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
.adaptive-nav__live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 16px;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__live-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
min-width: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--status-error);
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__live-chip {
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.06em;
|
||||
height: 24px !important;
|
||||
min-height: 24px !important;
|
||||
padding: 0 8px !important;
|
||||
color: var(--status-active) !important;
|
||||
--mdc-chip-elevated-container-color: transparent;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.adaptive-nav__live-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile Header (visible <600px only)
|
||||
// ---------------------------------------------------------------------------
|
||||
.adaptive-nav__mobile-header {
|
||||
display: none; // Hidden on desktop, shown on mobile via media query
|
||||
align-items: center;
|
||||
height: var(--cc-header-height-compact, 56px);
|
||||
padding: 0 12px;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
z-index: 20;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.adaptive-nav__hamburger {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-title {
|
||||
flex: 1;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.adaptive-nav__live--mobile {
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
|
||||
.adaptive-nav__live-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-action {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile Drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
.adaptive-nav__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-drawer {
|
||||
position: fixed;
|
||||
top: var(--cc-header-height-compact, 56px); // Below header
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 80vw;
|
||||
background-color: var(--cc-surface-container);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
z-index: 50;
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
animation: slide-in-left 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.adaptive-nav__drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 48px;
|
||||
padding: 0 20px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
|
||||
.adaptive-nav__drawer-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adaptive-nav__drawer-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawer slide-in animation
|
||||
// ---------------------------------------------------------------------------
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media Queries — Layout Switch (CUB-27 breakpoints)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Compact (0–599px): Show mobile header, hide sidebar
|
||||
@media (max-width: 599px) {
|
||||
.adaptive-nav__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Hide mobile drawer and overlay on desktop
|
||||
.adaptive-nav__overlay,
|
||||
.adaptive-nav__mobile-drawer {
|
||||
// These are conditional via @if in template, no display:none needed
|
||||
}
|
||||
}
|
||||
|
||||
// Medium (600–1023px): Show collapsed sidebar (icon-only), hide mobile
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.adaptive-nav__sidebar {
|
||||
display: flex;
|
||||
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||
}
|
||||
|
||||
// Hide labels on medium (collapsed)
|
||||
.adaptive-nav__sidebar-label,
|
||||
.adaptive-nav__brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar-item {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__overlay,
|
||||
.adaptive-nav__mobile-drawer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded (≥1024px): Show sidebar with labels
|
||||
@media (min-width: 1024px) {
|
||||
.adaptive-nav__sidebar {
|
||||
display: flex;
|
||||
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__overlay,
|
||||
.adaptive-nav__mobile-drawer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.adaptive-nav__live-dot--connected {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__mobile-drawer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.adaptive-nav__sidebar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, signal, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
/**
|
||||
* Adaptive Navigation Component — switches between desktop sidebar
|
||||
* and mobile header layouts using CSS media queries + JS breakpoint sync.
|
||||
*
|
||||
* Per CUB-27 spec breakpoints:
|
||||
* Compact (0–599px): Mobile header + hamburger + bottom nav
|
||||
* Medium (600–1023px): Collapsed sidebar (icon-only)
|
||||
* Expanded (≥1024px): Expandable sidebar (hover/click)
|
||||
*
|
||||
* The LIVE status indicator is visible in all layouts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-adaptive-navigation',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatBadgeModule,
|
||||
],
|
||||
templateUrl: './adaptive-navigation.component.html',
|
||||
styleUrl: './adaptive-navigation.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AdaptiveNavigationComponent implements OnInit, OnDestroy {
|
||||
/** Navigation destinations shared with other nav components */
|
||||
protected readonly destinations = NAV_DESTINATIONS;
|
||||
|
||||
/** Whether the mobile drawer is open */
|
||||
protected readonly mobileMenuOpen = signal(false);
|
||||
|
||||
/** Live connection status */
|
||||
protected readonly isConnected = signal(true);
|
||||
|
||||
/** Responsive breakpoint state */
|
||||
protected readonly isMedium = signal(false);
|
||||
protected readonly isExpanded = signal(false);
|
||||
|
||||
private readonly COMPACT_MAX = 599;
|
||||
private readonly MEDIUM_MAX = 1023;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updateBreakpoint();
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.updateBreakpoint();
|
||||
}
|
||||
|
||||
/** Toggle mobile menu */
|
||||
toggleMobileMenu(): void {
|
||||
this.mobileMenuOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
/** Close mobile menu (e.g. on nav) */
|
||||
closeMobileMenu(): void {
|
||||
this.mobileMenuOpen.set(false);
|
||||
}
|
||||
|
||||
private updateBreakpoint(): void {
|
||||
const w = window.innerWidth;
|
||||
this.isMedium.set(w >= this.COMPACT_MAX + 1 && w <= this.MEDIUM_MAX);
|
||||
this.isExpanded.set(w > this.MEDIUM_MAX);
|
||||
// Close mobile menu when switching to desktop
|
||||
if (w > this.COMPACT_MAX) {
|
||||
this.mobileMenuOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// HostListener auto-unsubscribes
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './adaptive-navigation.component';
|
||||
@@ -1,140 +0,0 @@
|
||||
<!-- ============================================================================
|
||||
Agent Session Drawer — CUB-26
|
||||
Desktop: 480px side drawer slides from right with modal overlay.
|
||||
Mobile: Bottom sheet slides up from bottom.
|
||||
Shows: Agent name, status badge, session key, live log tail,
|
||||
recent messages, and action buttons.
|
||||
============================================================================-->
|
||||
|
||||
<!-- Backdrop overlay -->
|
||||
@if (isOpen()) {
|
||||
<div
|
||||
class="session-drawer-backdrop"
|
||||
(click)="onBackdropClick()"
|
||||
[class.session-drawer-backdrop--visible]="isOpen()"
|
||||
></div>
|
||||
}
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div
|
||||
#drawerPanel
|
||||
class="session-drawer"
|
||||
[class.session-drawer--open]="isOpen()"
|
||||
[class.session-drawer--mobile]="isMobile"
|
||||
(keydown)="onDrawerKeydown($event)"
|
||||
role="dialog"
|
||||
aria-label="Agent session details"
|
||||
[attr.aria-hidden]="!isOpen()"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="session-drawer__header">
|
||||
@if (agent) {
|
||||
<div class="session-drawer__header-identity">
|
||||
<span class="status-dot {{ getStatusClass(agent.status) }}" [attr.aria-label]="getStatusLabel(agent.status)"></span>
|
||||
<div class="session-drawer__header-text">
|
||||
<h2 class="session-drawer__title">{{ agent.displayName }}</h2>
|
||||
<span class="session-drawer__role">{{ agent.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
class="session-drawer__close-btn"
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
(click)="close()"
|
||||
matIconButton
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
@if (agent) {
|
||||
<div class="session-drawer__content">
|
||||
|
||||
<!-- Status & Session Key Section -->
|
||||
<section class="session-drawer__section">
|
||||
<div class="session-drawer__meta-row">
|
||||
<span class="session-drawer__status-chip {{ getStatusChipColor(agent.status) }}">
|
||||
{{ getStatusLabel(agent.status) }}
|
||||
</span>
|
||||
@if (agent.channel) {
|
||||
<span class="session-drawer__channel-badge">
|
||||
<mat-icon class="session-drawer__channel-icon">chat</mat-icon>
|
||||
{{ agent.channel }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="session-drawer__session-key">
|
||||
<span class="session-drawer__label">Session Key</span>
|
||||
<code class="session-drawer__key-value">{{ agent.sessionKey }}</code>
|
||||
</div>
|
||||
@if (agent.currentTask) {
|
||||
<div class="session-drawer__task-info">
|
||||
<span class="session-drawer__label">Current Task</span>
|
||||
<span class="session-drawer__task-text">{{ agent.currentTask }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="session-drawer__last-activity">
|
||||
<span class="session-drawer__label">Last Activity</span>
|
||||
<span class="session-drawer__activity-time">{{ formatRelativeTime(agent.lastActivity) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent Messages Section -->
|
||||
<section class="session-drawer__section">
|
||||
<h3 class="session-drawer__section-title">Recent Messages</h3>
|
||||
<div class="session-drawer__messages">
|
||||
@for (msg of recentMessages(); track msg.id) {
|
||||
<div class="session-drawer__message session-drawer__message--{{ msg.sender }}">
|
||||
<span class="session-drawer__message-sender">
|
||||
{{ msg.sender === 'agent' ? agent.displayName : 'You' }}
|
||||
</span>
|
||||
<p class="session-drawer__message-text">{{ msg.content }}</p>
|
||||
<span class="session-drawer__message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="session-drawer__empty-state">No recent messages</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live Log Tail Section -->
|
||||
<section class="session-drawer__section">
|
||||
<h3 class="session-drawer__section-title">Live Log</h3>
|
||||
<div class="session-drawer__log-container">
|
||||
@for (line of logLines(); track $index) {
|
||||
<div class="session-drawer__log-line {{ getLogLevelClass(line.level) }}">
|
||||
<span class="session-drawer__log-time">{{ formatTime(line.timestamp) }}</span>
|
||||
<span class="session-drawer__log-level">{{ line.level.toUpperCase() }}</span>
|
||||
<span class="session-drawer__log-message">{{ line.message }}</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="session-drawer__empty-state">No log output</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Action buttons (sticky footer) -->
|
||||
<div class="session-drawer__actions">
|
||||
<button
|
||||
class="session-drawer__action-btn session-drawer__action-btn--primary"
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onOpenSession()"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
Open Full Session
|
||||
</button>
|
||||
<button
|
||||
class="session-drawer__action-btn session-drawer__action-btn--secondary"
|
||||
mat-stroked-button
|
||||
(click)="onPinToDashboard()"
|
||||
>
|
||||
<mat-icon>push_pin</mat-icon>
|
||||
Pin to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,500 +0,0 @@
|
||||
// ============================================================================
|
||||
// Agent Session Drawer — CUB-26
|
||||
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||
// Mobile: Bottom sheet slides up from bottom.
|
||||
// Uses Control Center design tokens from CUB-21.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backdrop
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawer Panel — Desktop: Side drawer from right
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 480px;
|
||||
max-width: 100vw;
|
||||
background-color: var(--cc-surface-container);
|
||||
border-left: 1px solid var(--cc-outline);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
|
||||
&--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile: Bottom Sheet — slides up from bottom
|
||||
// ---------------------------------------------------------------------------
|
||||
&--mobile {
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 85vh;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
border-radius: 20px 20px 0 0;
|
||||
transform: translateY(100%);
|
||||
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||
|
||||
&.session-drawer--open {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
// Drag handle for mobile bottom sheet
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--cc-on-surface-variant);
|
||||
opacity: 0.4;
|
||||
margin: 8px auto 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-drawer__header-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-drawer__header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-drawer__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-drawer__role {
|
||||
font-size: 13px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-drawer__close-btn {
|
||||
--mat-icon-button-state-layer-color: transparent;
|
||||
color: var(--cc-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content — scrollable area
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 24px 16px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sections
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__section {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-drawer__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta Row — Status + Channel
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-drawer__status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
&.status-chip--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
&.status-chip--idle {
|
||||
background-color: var(--status-idle-bg);
|
||||
color: var(--status-idle);
|
||||
}
|
||||
|
||||
&.status-chip--thinking {
|
||||
background-color: var(--status-thinking-bg);
|
||||
color: var(--status-thinking);
|
||||
}
|
||||
|
||||
&.status-chip--error {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
&.status-chip--offline {
|
||||
background-color: rgba(100, 116, 139, 0.12);
|
||||
color: var(--status-offline);
|
||||
}
|
||||
}
|
||||
|
||||
.session-drawer__channel-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--cc-surface-container-high);
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
|
||||
.session-drawer__channel-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session Key
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__session-key {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-drawer__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-drawer__key-value {
|
||||
display: block;
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-secondary);
|
||||
background: var(--cc-surface);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cc-outline);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task Info
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__task-info {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-drawer__task-text {
|
||||
font-size: 14px;
|
||||
color: var(--cc-on-surface);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last Activity
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__last-activity {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.session-drawer__activity-time {
|
||||
font-size: 14px;
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent Messages
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-drawer__message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
max-width: 100%;
|
||||
|
||||
&--agent {
|
||||
background: var(--cc-surface-container-high);
|
||||
border: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
&--user {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.session-drawer__message-sender {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface-variant);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-drawer__message-text {
|
||||
font-size: 14px;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.session-drawer__message-time {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
margin-top: 4px;
|
||||
font-family: var(--cc-font-mono);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Log Container
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__log-container {
|
||||
background: var(--cc-surface);
|
||||
border: 1px solid var(--cc-outline);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.session-drawer__log-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&--info {
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
color: #FBBF24;
|
||||
}
|
||||
|
||||
&--error {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
&--debug {
|
||||
color: var(--cc-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.session-drawer__log-time {
|
||||
color: var(--cc-on-surface-variant);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.session-drawer__log-level {
|
||||
width: 48px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.session-drawer__log-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty State
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__empty-state {
|
||||
font-size: 13px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Buttons — Sticky Footer
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
background-color: var(--cc-surface-container);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-drawer__action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
|
||||
.mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile Adjustments
|
||||
// ---------------------------------------------------------------------------
|
||||
.session-drawer--mobile {
|
||||
.session-drawer__header {
|
||||
padding: 12px 20px 12px;
|
||||
}
|
||||
|
||||
.session-drawer__content {
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.session-drawer__actions {
|
||||
padding: 12px 20px 16px;
|
||||
flex-direction: column;
|
||||
|
||||
.session-drawer__action-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.session-drawer__log-container {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responsive — wider viewports keep 480px, narrow go full-width
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 599px) {
|
||||
.session-drawer:not(.session-drawer--mobile) {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-drawer {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.session-drawer-backdrop {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { AgentCardData, AgentStatus } from '../../models/agent.model';
|
||||
|
||||
// ============================================================================
|
||||
// Agent Session Drawer — Per CUB-26
|
||||
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||
// Mobile: Bottom sheet slides up from bottom.
|
||||
// Shows: Agent name, status badge, session key, live log tail,
|
||||
// recent messages, and action buttons.
|
||||
// ============================================================================
|
||||
|
||||
export interface SessionLogLine {
|
||||
timestamp: Date;
|
||||
level: 'info' | 'warn' | 'error' | 'debug';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
id: string;
|
||||
sender: 'agent' | 'user';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-agent-session-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule, MatChipsModule],
|
||||
templateUrl: './agent-session-drawer.component.html',
|
||||
styleUrl: './agent-session-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AgentSessionDrawerComponent implements OnDestroy {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The agent whose session details are displayed. */
|
||||
@Input() set agent(value: AgentCardData | null) {
|
||||
this._agent = value;
|
||||
if (value) {
|
||||
this.isOpen.set(true);
|
||||
this.loadSessionData(value);
|
||||
}
|
||||
}
|
||||
get agent(): AgentCardData | null {
|
||||
return this._agent;
|
||||
}
|
||||
private _agent: AgentCardData | null = null;
|
||||
|
||||
/** Whether this is mobile viewport (bottom sheet mode). */
|
||||
@Input() isMobile = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Emitted when the user clicks "Open Full Session". Payload is the session key. */
|
||||
@Output() readonly openSession = new EventEmitter<string>();
|
||||
|
||||
/** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */
|
||||
@Output() readonly pinToDashboard = new EventEmitter<string>();
|
||||
|
||||
/** Emitted when the drawer closes. */
|
||||
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
readonly logLines = signal<SessionLogLine[]>([]);
|
||||
readonly recentMessages = signal<SessionMessage[]>([]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View Children
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getStatusClass(status: string): string {
|
||||
return `status-dot--${status}`;
|
||||
}
|
||||
|
||||
getStatusLabel(status: AgentStatus): string {
|
||||
const labels: Record<AgentStatus, string> = {
|
||||
active: 'Active',
|
||||
idle: 'Idle',
|
||||
thinking: 'Thinking…',
|
||||
error: 'Error',
|
||||
offline: 'Offline',
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
getStatusChipColor(status: AgentStatus): string {
|
||||
const map: Record<AgentStatus, string> = {
|
||||
active: 'status-chip--active',
|
||||
idle: 'status-chip--idle',
|
||||
thinking: 'status-chip--thinking',
|
||||
error: 'status-chip--error',
|
||||
offline: 'status-chip--offline',
|
||||
};
|
||||
return map[status] ?? '';
|
||||
}
|
||||
|
||||
getLogLevelClass(level: SessionLogLine['level']): string {
|
||||
return `log-line--${level}`;
|
||||
}
|
||||
|
||||
/** Format a date to a short time string. */
|
||||
formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
/** Format a date to a relative time string. */
|
||||
formatRelativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const then = date.getTime();
|
||||
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open the drawer for a specific agent. */
|
||||
open(agentData: AgentCardData): void {
|
||||
this._agent = agentData;
|
||||
this.isOpen.set(true);
|
||||
this.loadSessionData(agentData);
|
||||
}
|
||||
|
||||
/** Close the drawer. */
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
this.drawerClose.emit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle keyboard navigation within the drawer. */
|
||||
onDrawerKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
// Tab through actions — browser default Tab behavior is fine,
|
||||
// we just trap focus within the drawer
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outside Click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onBackdropClick(): void {
|
||||
this.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
onOpenSession(): void {
|
||||
if (this._agent) {
|
||||
this.openSession.emit(this._agent.sessionKey);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
onPinToDashboard(): void {
|
||||
if (this._agent) {
|
||||
this.pinToDashboard.emit(this._agent.sessionKey);
|
||||
}
|
||||
// Don't close — user may want to keep viewing
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Clean up any subscriptions when needed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Load mock session data for the agent (TODO: wire to real data service). */
|
||||
private loadSessionData(agentData: AgentCardData): void {
|
||||
// TODO: Replace with real session data service when available.
|
||||
// For now, generate placeholder log lines and messages.
|
||||
const now = new Date();
|
||||
const logLines: SessionLogLine[] = [];
|
||||
for (let i = 19; i >= 0; i--) {
|
||||
const ts = new Date(now.getTime() - i * 5000);
|
||||
const levels: SessionLogLine['level'][] = ['info', 'info', 'info', 'debug', 'warn'];
|
||||
const messages = [
|
||||
`Processing task queue for ${agentData.displayName}`,
|
||||
`SignalR heartbeat OK`,
|
||||
`Session state: active`,
|
||||
`Checking for pending commands…`,
|
||||
`Updating task progress: ${Math.floor(Math.random() * 100)}%`,
|
||||
];
|
||||
logLines.push({
|
||||
timestamp: ts,
|
||||
level: levels[i % levels.length],
|
||||
message: messages[i % messages.length],
|
||||
});
|
||||
}
|
||||
this.logLines.set(logLines);
|
||||
|
||||
const recentMessages: SessionMessage[] = [
|
||||
{
|
||||
id: '1',
|
||||
sender: 'user',
|
||||
content: `Hey ${agentData.displayName}, how's the current task going?`,
|
||||
timestamp: new Date(now.getTime() - 120000),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sender: 'agent',
|
||||
content: agentData.currentTask ?? 'Working on it — progress is steady.',
|
||||
timestamp: new Date(now.getTime() - 115000),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
sender: 'user',
|
||||
content: 'Great, let me know if you hit any blockers.',
|
||||
timestamp: new Date(now.getTime() - 110000),
|
||||
},
|
||||
];
|
||||
this.recentMessages.set(recentMessages);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AgentSessionDrawerComponent } from './agent-session-drawer.component';
|
||||
export type { SessionLogLine, SessionMessage } from './agent-session-drawer.component';
|
||||
@@ -1,8 +0,0 @@
|
||||
<span class="badge"
|
||||
[class]="statusClass"
|
||||
[class.badge--pulse]="hasPulse"
|
||||
[attr.aria-label]="'Agent status: ' + displayLabel"
|
||||
role="status">
|
||||
<span class="badge__dot"></span>
|
||||
<span class="badge__label">{{ displayLabel }}</span>
|
||||
</span>
|
||||
@@ -1,146 +0,0 @@
|
||||
// ============================================================================
|
||||
// Agent Status Badge — per spec Section 7.3
|
||||
// Colored pill with dot indicator and optional pulse animation.
|
||||
// ============================================================================
|
||||
|
||||
$badge-height: 24px;
|
||||
$dot-size: 8px;
|
||||
$border-radius: 12px;
|
||||
$font-size: 12px;
|
||||
$font-weight: 500;
|
||||
$padding-x: 8px;
|
||||
$gap: 6px;
|
||||
|
||||
@use 'sass:color';
|
||||
|
||||
// Status color palette
|
||||
$color-active: #22c55e; // green-500
|
||||
$color-idle: #9ca3af; // gray-400
|
||||
$color-thinking: #3b82f6; // blue-500
|
||||
$color-error: #ef4444; // red-500
|
||||
$color-offline: #9ca3af; // gray-400
|
||||
|
||||
// Background tints (12% opacity for soft pill background)
|
||||
$bg-active: rgba($color-active, 0.12);
|
||||
$bg-idle: rgba($color-idle, 0.12);
|
||||
$bg-thinking: rgba($color-thinking, 0.12);
|
||||
$bg-error: rgba($color-error, 0.12);
|
||||
$bg-offline: rgba($color-offline, 0.12);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base pill
|
||||
// ---------------------------------------------------------------------------
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: $badge-height;
|
||||
padding: 0 $padding-x;
|
||||
border-radius: $border-radius;
|
||||
gap: $gap;
|
||||
font-size: $font-size;
|
||||
font-weight: $font-weight;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dot indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
.badge__dot {
|
||||
width: $dot-size;
|
||||
height: $dot-size;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label text
|
||||
// ---------------------------------------------------------------------------
|
||||
.badge__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status color variants
|
||||
// ---------------------------------------------------------------------------
|
||||
.badge--active {
|
||||
background: $bg-active;
|
||||
color: color.adjust($color-active, $lightness: -10%);
|
||||
|
||||
.badge__dot {
|
||||
background: $color-active;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--idle {
|
||||
background: $bg-idle;
|
||||
color: color.adjust($color-idle, $lightness: -15%);
|
||||
|
||||
.badge__dot {
|
||||
background: $color-idle;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--thinking {
|
||||
background: $bg-thinking;
|
||||
color: color.adjust($color-thinking, $lightness: -10%);
|
||||
|
||||
.badge__dot {
|
||||
background: $color-thinking;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--error {
|
||||
background: $bg-error;
|
||||
color: color.adjust($color-error, $lightness: -10%);
|
||||
|
||||
.badge__dot {
|
||||
background: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
.badge--offline {
|
||||
background: $bg-offline;
|
||||
color: color.adjust($color-offline, $lightness: -15%);
|
||||
|
||||
.badge__dot {
|
||||
background: $color-offline;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pulse animation — applied when status is active, thinking, or error
|
||||
// ---------------------------------------------------------------------------
|
||||
.badge--pulse {
|
||||
.badge__dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Active: 2s pulse
|
||||
.badge--active.badge--pulse .badge__dot {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
// Thinking: 3s pulse
|
||||
.badge--thinking.badge--pulse .badge__dot {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
|
||||
// Error: 0.8s pulse (fast, urgent)
|
||||
.badge--error.badge--pulse .badge__dot {
|
||||
animation-duration: 0.8s;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { AgentStatus } from '../../models/agent.model';
|
||||
|
||||
/**
|
||||
* Agent Status Badge component.
|
||||
* Displays a colored pill with a pulse animation indicating the agent's current status.
|
||||
* Per spec Section 7.3: Agent Card Component Interface — status indicator.
|
||||
*
|
||||
* Color mapping:
|
||||
* - Active → green
|
||||
* - Idle → gray
|
||||
* - Thinking → blue
|
||||
* - Error → red
|
||||
* - Offline → gray (no pulse)
|
||||
*
|
||||
* Pulse animations:
|
||||
* - Active → 2s
|
||||
* - Error → 0.8s
|
||||
* - Thinking → 3s
|
||||
* - Idle / Offline → no pulse
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-agent-status-badge',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './agent-status-badge.component.html',
|
||||
styleUrl: './agent-status-badge.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AgentStatusBadgeComponent {
|
||||
/** Current agent status — binds to the AgentStatus type from the model. */
|
||||
readonly status = input.required<AgentStatus>();
|
||||
|
||||
/** Label text shown inside the badge. Defaults to title-cased status. */
|
||||
readonly label = input<string>();
|
||||
|
||||
get displayLabel(): string {
|
||||
return this.label() ?? this.titleCase(this.status());
|
||||
}
|
||||
|
||||
/** CSS class driven by the current status value. */
|
||||
get statusClass(): string {
|
||||
return `badge--${this.status()}`;
|
||||
}
|
||||
|
||||
/** Whether the pulse animation should be active for the current status. */
|
||||
get hasPulse(): boolean {
|
||||
return this.status() === 'active' || this.status() === 'thinking' || this.status() === 'error';
|
||||
}
|
||||
|
||||
private titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AgentStatusBadgeComponent } from './agent-status-badge.component';
|
||||
@@ -1,31 +0,0 @@
|
||||
<!-- Backdrop overlay — click to dismiss -->
|
||||
<div class="global-action-modal__backdrop" #backdrop (click)="onBackdropClick()"></div>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class="global-action-modal__panel" (click)="onModalClick($event)" role="dialog" aria-modal="true" aria-label="Global Actions">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="global-action-modal__header">
|
||||
<h2 class="global-action-modal__title">Global Actions</h2>
|
||||
<button matIconButton
|
||||
class="global-action-modal__close"
|
||||
aria-label="Close modal"
|
||||
(click)="onClose()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action grid -->
|
||||
<div class="global-action-modal__actions">
|
||||
@for (action of actions; track action.key) {
|
||||
<button class="global-action-modal__action-btn global-action-modal__action-btn--{{ action.color }}"
|
||||
(click)="onAction(action)">
|
||||
<div class="global-action-modal__action-icon">
|
||||
<mat-icon>{{ action.icon }}</mat-icon>
|
||||
</div>
|
||||
<span class="global-action-modal__action-label">{{ action.label }}</span>
|
||||
<span class="global-action-modal__action-desc">{{ action.description }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,198 +0,0 @@
|
||||
// ============================================================================
|
||||
// Global Action Modal — Tactical Dark Mode Styling
|
||||
// Uses Control Center design tokens from styles.scss
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backdrop
|
||||
// ---------------------------------------------------------------------------
|
||||
:host {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.global-action-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
.global-action-modal__panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(560px, calc(100vw - 48px));
|
||||
background: var(--cc-surface-container);
|
||||
border: 1px solid var(--cc-outline);
|
||||
border-radius: var(--cc-card-border-radius);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header
|
||||
// ---------------------------------------------------------------------------
|
||||
.global-action-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 12px;
|
||||
}
|
||||
|
||||
.global-action-modal__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.global-action-modal__close {
|
||||
--mat-icon-button-state-layer-color: transparent;
|
||||
color: var(--cc-on-surface-variant);
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Grid
|
||||
// ---------------------------------------------------------------------------
|
||||
.global-action-modal__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px 24px 24px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Button
|
||||
// ---------------------------------------------------------------------------
|
||||
.global-action-modal__action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid var(--cc-outline);
|
||||
border-radius: 12px;
|
||||
background: var(--cc-surface);
|
||||
color: var(--cc-on-surface);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--cc-surface-container-high);
|
||||
border-color: var(--cc-on-surface-variant);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary, #38BDF8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Action icon wrapper
|
||||
.global-action-modal__action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
|
||||
.mat-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
// Action label
|
||||
.global-action-modal__action-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
// Action description
|
||||
.global-action-modal__action-desc {
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color Variants — per-action accent colors
|
||||
// ---------------------------------------------------------------------------
|
||||
.global-action-modal__action-btn--deploy {
|
||||
.global-action-modal__action-icon {
|
||||
background: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--status-active);
|
||||
}
|
||||
}
|
||||
|
||||
.global-action-modal__action-btn--pause {
|
||||
.global-action-modal__action-icon {
|
||||
background: var(--status-idle-bg);
|
||||
color: var(--status-idle);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--status-idle);
|
||||
}
|
||||
}
|
||||
|
||||
.global-action-modal__action-btn--emergency {
|
||||
.global-action-modal__action-icon {
|
||||
background: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
.global-action-modal__action-label {
|
||||
color: var(--status-error);
|
||||
}
|
||||
}
|
||||
|
||||
.global-action-modal__action-btn--add {
|
||||
.global-action-modal__action-icon {
|
||||
background: var(--status-thinking-bg);
|
||||
color: var(--status-thinking);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--status-thinking);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responsive — stack single column on narrow viewports
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 400px) {
|
||||
.global-action-modal__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
|
||||
/**
|
||||
* Global Action Modal — overlay for fleet-wide commands.
|
||||
*
|
||||
* Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent.
|
||||
* Tactical Dark Mode styling using Control Center design tokens.
|
||||
* Dismisses on backdrop click or close button.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-global-action-modal',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, MatButtonModule],
|
||||
templateUrl: './global-action-modal.component.html',
|
||||
styleUrl: './global-action-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GlobalActionModalComponent {
|
||||
/** Emitted when any action button is clicked. Payload is the action key. */
|
||||
@Output() readonly actionSelected = new EventEmitter<GlobalAction>();
|
||||
|
||||
/** Emitted when the modal is dismissed (backdrop click or close button). */
|
||||
@Output() readonly dismissed = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('backdrop') backdropEl!: ElementRef<HTMLElement>;
|
||||
|
||||
/** All available global actions. */
|
||||
readonly actions: GlobalActionDef[] = [
|
||||
{
|
||||
key: 'deploy-all',
|
||||
label: 'Deploy All',
|
||||
description: 'Deploy all agents in the fleet',
|
||||
icon: 'rocket_launch',
|
||||
color: 'deploy',
|
||||
},
|
||||
{
|
||||
key: 'pause-all',
|
||||
label: 'Pause All',
|
||||
description: 'Pause all running agents',
|
||||
icon: 'pause_circle',
|
||||
color: 'pause',
|
||||
},
|
||||
{
|
||||
key: 'emergency-stop',
|
||||
label: 'Emergency Stop',
|
||||
description: 'Immediately halt all agents',
|
||||
icon: 'emergency',
|
||||
color: 'emergency',
|
||||
},
|
||||
{
|
||||
key: 'add-agent',
|
||||
label: 'Add Agent',
|
||||
description: 'Register a new agent to the fleet',
|
||||
icon: 'person_add',
|
||||
color: 'add',
|
||||
},
|
||||
];
|
||||
|
||||
onBackdropClick(): void {
|
||||
this.dismissed.emit();
|
||||
}
|
||||
|
||||
onModalClick(event: Event): void {
|
||||
// Prevent clicks inside the modal panel from closing it
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.dismissed.emit();
|
||||
}
|
||||
|
||||
onAction(action: GlobalActionDef): void {
|
||||
this.actionSelected.emit(action.key);
|
||||
}
|
||||
}
|
||||
|
||||
export type GlobalAction = 'deploy-all' | 'pause-all' | 'emergency-stop' | 'add-agent';
|
||||
|
||||
export interface GlobalActionDef {
|
||||
key: GlobalAction;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: 'deploy' | 'pause' | 'emergency' | 'add';
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './quick-jump-button/quick-jump-button.component';
|
||||
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
|
||||
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
|
||||
export { AgentSessionDrawerComponent } from './agent-session-drawer/index';
|
||||
export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index';
|
||||
@@ -1,8 +0,0 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
class="quick-jump-button"
|
||||
[attr.aria-label]="'Jump to agent session'"
|
||||
(click)="onJumpClick()"
|
||||
>
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
@@ -1,68 +0,0 @@
|
||||
// ============================================================================
|
||||
// Quick-Jump Button — M3 FilledTonalIconButton
|
||||
// Per spec Section 7.3: Agent Card Quick-Jump action
|
||||
// M3 spec: FilledTonalIconButton uses secondary container color
|
||||
// with 8% state layer overlay for hover/focus.
|
||||
// ============================================================================
|
||||
|
||||
.quick-jump-button {
|
||||
// M3 FilledTonalIconButton: secondary-container background
|
||||
// Angular Material mat-icon-button sets up the base shape (40x40, round).
|
||||
// We override the color tokens to match FilledTonal style.
|
||||
--mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container);
|
||||
background-color: var(--mat-sys-secondary-container);
|
||||
border-radius: 50%;
|
||||
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// M3 State Layer: 8% overlay on hover
|
||||
&:hover {
|
||||
background-color: var(--mat-sys-secondary-container);
|
||||
// State layer overlay using a pseudo-element for precise 8% opacity
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mat-sys-on-secondary-container);
|
||||
opacity: 0.08;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility)
|
||||
&:focus-visible {
|
||||
background-color: var(--mat-sys-secondary-container);
|
||||
outline: 3px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mat-sys-on-secondary-container);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// M3 State Layer: 12% overlay on active/pressed
|
||||
&:active {
|
||||
background-color: var(--mat-sys-secondary-container);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--mat-sys-on-secondary-container);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon color stays on-secondary-container across all states
|
||||
.mat-icon {
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
/**
|
||||
* Quick-Jump Button — M3 FilledTonalIconButton
|
||||
*
|
||||
* An icon button that emits a navigation event for jumping to an agent session.
|
||||
* Uses the Material Design 3 FilledTonalIconButton style with 8% state layer
|
||||
* overlay on hover and focus.
|
||||
*
|
||||
* Per spec Section 7.3: Agent Card Component Interface
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-quick-jump-button',
|
||||
standalone: true,
|
||||
imports: [MatIconButton, MatIcon],
|
||||
templateUrl: './quick-jump-button.component.html',
|
||||
styleUrl: './quick-jump-button.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class QuickJumpButtonComponent {
|
||||
/** Emitted when the button is clicked, carrying the session key for navigation. */
|
||||
@Output() jumpClick = new EventEmitter<string>();
|
||||
|
||||
/** The session key to navigate to. Set by the parent agent card. */
|
||||
sessionKey = '';
|
||||
|
||||
onJumpClick(): void {
|
||||
this.jumpClick.emit(this.sessionKey);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { QuickJumpDrawerComponent } from './quick-jump-drawer.component';
|
||||
@@ -1,109 +0,0 @@
|
||||
<!-- ============================================================================
|
||||
Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||
Per CUB-51: Slides from right, agent list with status badges,
|
||||
search/filter input, closes via ESC or outside click.
|
||||
============================================================================-->
|
||||
|
||||
<!-- Backdrop overlay -->
|
||||
@if (isOpen()) {
|
||||
<div
|
||||
class="quick-jump-backdrop"
|
||||
(click)="onBackdropClick($event)"
|
||||
[@backdropEnter]
|
||||
></div>
|
||||
}
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div
|
||||
class="quick-jump-drawer"
|
||||
[class.quick-jump-drawer--open]="isOpen()"
|
||||
(keydown)="onDrawerKeydown($event)"
|
||||
role="dialog"
|
||||
aria-label="Quick jump to agent"
|
||||
[attr.aria-hidden]="!isOpen()"
|
||||
>
|
||||
<!-- Drawer header -->
|
||||
<div class="quick-jump-drawer__header">
|
||||
<h2 class="quick-jump-drawer__title">Jump to Agent</h2>
|
||||
<button
|
||||
class="quick-jump-drawer__close-btn"
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
(click)="close()"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search input -->
|
||||
<div class="quick-jump-drawer__search">
|
||||
<span class="quick-jump-drawer__search-icon">search</span>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
class="quick-jump-drawer__search-input"
|
||||
placeholder="Search agents..."
|
||||
[formControl]="searchControl"
|
||||
autocomplete="off"
|
||||
aria-label="Search agents"
|
||||
/>
|
||||
@if (searchControl.value) {
|
||||
<button
|
||||
class="quick-jump-drawer__search-clear"
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
(click)="searchControl.setValue('')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Agent list -->
|
||||
<ul class="quick-jump-drawer__agent-list" role="listbox" aria-label="Agent list">
|
||||
@for (agent of filteredAgents(); track agent.id; let i = $index) {
|
||||
<li
|
||||
[id]="'quick-jump-agent-' + i"
|
||||
class="quick-jump-drawer__agent-item"
|
||||
[class.quick-jump-drawer__agent-item--highlighted]="highlightedIndex() === i"
|
||||
role="option"
|
||||
[attr.aria-selected]="highlightedIndex() === i"
|
||||
(click)="selectAgent(agent)"
|
||||
(mouseenter)="highlightedIndex.set(i)"
|
||||
(mouseleave)="highlightedIndex.set(-1)"
|
||||
>
|
||||
<!-- Status badge -->
|
||||
<span
|
||||
class="status-dot {{ getStatusClass(agent.status) }}"
|
||||
[attr.aria-label]="getStatusLabel(agent.status)"
|
||||
></span>
|
||||
|
||||
<!-- Agent info -->
|
||||
<div class="quick-jump-drawer__agent-info">
|
||||
<span class="quick-jump-drawer__agent-name">{{ agent.displayName }}</span>
|
||||
<span class="quick-jump-drawer__agent-role">{{ agent.role }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status label -->
|
||||
<span class="quick-jump-drawer__agent-status-label" [class]="'status-label--' + agent.status">
|
||||
{{ getStatusLabel(agent.status) }}
|
||||
</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="quick-jump-drawer__empty">
|
||||
@if (searchControl.value) {
|
||||
<span>No agents matching "{{ searchControl.value }}"</span>
|
||||
} @else {
|
||||
<span>No agents online</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<!-- Footer hint -->
|
||||
<div class="quick-jump-drawer__footer">
|
||||
<span class="quick-jump-drawer__footer-hint">
|
||||
<kbd>↑↓</kbd> Navigate <kbd>↵</kbd> Select <kbd>Esc</kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,333 +0,0 @@
|
||||
// ============================================================================
|
||||
// Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||
// Per CUB-51: slides from right, agent list with status badges,
|
||||
// search/filter input, closes via ESC or outside click.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backdrop
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
|
||||
&.backdrop-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drawer Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
background-color: var(--cc-surface-container);
|
||||
border-left: 1px solid var(--cc-outline);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.quick-jump-drawer__title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--cc-surface-container-high);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer__search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 24px 8px;
|
||||
border: 1px solid var(--cc-outline);
|
||||
border-radius: 12px;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
transition: border-color 150ms ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--status-active);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-jump-drawer__search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 12px;
|
||||
font-family: 'Material Icons';
|
||||
font-size: 20px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
// Use a simple "search" text since icon font may not be loaded inside
|
||||
// the drawer — rely on Material icon font from the parent app
|
||||
&::before {
|
||||
content: 'search';
|
||||
font-family: 'Material Icons';
|
||||
}
|
||||
}
|
||||
|
||||
.quick-jump-drawer__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: 12px 8px;
|
||||
font-size: 15px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
color: var(--cc-on-surface);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--cc-on-surface-variant);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-jump-drawer__search-clear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--cc-surface-container);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent List
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer__agent-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&--highlighted {
|
||||
background-color: var(--cc-surface-container-high);
|
||||
}
|
||||
|
||||
&--highlighted {
|
||||
outline: 2px solid var(--status-active);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0; // Allow text truncation
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-role {
|
||||
font-size: 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-status-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.status-label--active {
|
||||
color: var(--status-active);
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
|
||||
&.status-label--idle {
|
||||
color: var(--status-idle);
|
||||
background-color: var(--status-idle-bg);
|
||||
}
|
||||
|
||||
&.status-label--thinking {
|
||||
color: var(--status-thinking);
|
||||
background-color: var(--status-thinking-bg);
|
||||
}
|
||||
|
||||
&.status-label--error {
|
||||
color: var(--status-error);
|
||||
background-color: var(--status-error-bg);
|
||||
}
|
||||
|
||||
&.status-label--offline {
|
||||
color: var(--status-offline);
|
||||
background-color: rgba(100, 116, 139, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty State
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer
|
||||
// ---------------------------------------------------------------------------
|
||||
.quick-jump-drawer__footer {
|
||||
padding: 12px 24px 16px;
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.quick-jump-drawer__footer-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
opacity: 0.7;
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--cc-font-mono);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border: 1px solid var(--cc-outline);
|
||||
border-radius: 4px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile Adjustments
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 599px) {
|
||||
.quick-jump-drawer {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__header {
|
||||
padding: 16px 16px 10px;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__search {
|
||||
margin: 12px 16px 8px;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__agent-list {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.quick-jump-drawer__footer {
|
||||
padding: 10px 16px 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
Output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { AgentCardData } from '../../models/agent.model';
|
||||
import { AgentStatusService } from '../../services/agent-status.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-quick-jump-drawer',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
templateUrl: './quick-jump-drawer.component.html',
|
||||
styleUrl: './quick-jump-drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class QuickJumpDrawerComponent implements OnDestroy {
|
||||
/** Emits when the drawer should close (ESC, outside click, or item select). */
|
||||
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||
|
||||
/** Whether the drawer is visible. */
|
||||
readonly isOpen = signal(false);
|
||||
|
||||
/** Search/filter input control. */
|
||||
readonly searchControl = new FormControl('', { nonNullable: true });
|
||||
|
||||
/** Filtered agent list based on search. */
|
||||
readonly filteredAgents = signal<AgentCardData[]>([]);
|
||||
|
||||
/** Track which agent row is highlighted via keyboard navigation. */
|
||||
readonly highlightedIndex = signal(-1);
|
||||
|
||||
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(private readonly agentStatusService: AgentStatusService) {
|
||||
// Reactively filter agents as the search input changes
|
||||
this.searchControl.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((query) => this.filterAgents(query));
|
||||
|
||||
// Initial load
|
||||
this.filterAgents('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open the drawer and focus the search input. */
|
||||
open(): void {
|
||||
this.isOpen.set(true);
|
||||
this.searchControl.setValue('', { emitEvent: false });
|
||||
this.highlightedIndex.set(-1);
|
||||
// Focus search input after animation frame (drawer needs to render first)
|
||||
requestAnimationFrame(() => {
|
||||
this.searchInput?.nativeElement?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the drawer. */
|
||||
close(): void {
|
||||
this.isOpen.set(false);
|
||||
this.searchControl.setValue('', { emitEvent: false });
|
||||
this.highlightedIndex.set(-1);
|
||||
this.drawerClose.emit();
|
||||
}
|
||||
|
||||
/** Toggle the drawer open/close. */
|
||||
toggle(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.isOpen()) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle keyboard navigation within the drawer panel. */
|
||||
onDrawerKeydown(event: KeyboardEvent): void {
|
||||
const agents = this.filteredAgents();
|
||||
if (!agents.length) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex.update((i) =>
|
||||
i < agents.length - 1 ? i + 1 : 0
|
||||
);
|
||||
this.scrollIntoView();
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
this.highlightedIndex.update((i) =>
|
||||
i > 0 ? i - 1 : agents.length - 1
|
||||
);
|
||||
this.scrollIntoView();
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
const idx = this.highlightedIndex();
|
||||
if (idx >= 0 && idx < agents.length) {
|
||||
this.selectAgent(agents[idx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outside Click
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Close when clicking on the backdrop (outside the panel). */
|
||||
onBackdropClick(event: MouseEvent): void {
|
||||
if (
|
||||
this.drawerPanel?.nativeElement &&
|
||||
!this.drawerPanel.nativeElement.contains(event.target as Node)
|
||||
) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Select an agent — navigates or focuses the agent card. */
|
||||
selectAgent(agent: AgentCardData): void {
|
||||
// TODO: Wire up navigation to the selected agent's detail view
|
||||
// For now, emit close after selection
|
||||
console.log('[QuickJump] Selected agent:', agent.id);
|
||||
this.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get the CSS class for a given agent status. */
|
||||
getStatusClass(status: string): string {
|
||||
return `status-dot--${status}`;
|
||||
}
|
||||
|
||||
/** Get a human-readable label for an agent status. */
|
||||
getStatusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
idle: 'Idle',
|
||||
thinking: 'Thinking',
|
||||
error: 'Error',
|
||||
offline: 'Offline',
|
||||
};
|
||||
return labels[status] ?? status;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private filterAgents(query: string): void {
|
||||
const allAgents = this.agentStatusService.agents();
|
||||
const lowerQuery = query.toLowerCase().trim();
|
||||
|
||||
if (!lowerQuery) {
|
||||
this.filteredAgents.set(allAgents);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allAgents.filter(
|
||||
(agent) =>
|
||||
agent.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
agent.id.toLowerCase().includes(lowerQuery) ||
|
||||
agent.role.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
this.filteredAgents.set(filtered);
|
||||
this.highlightedIndex.set(-1);
|
||||
}
|
||||
|
||||
private scrollIntoView(): void {
|
||||
const idx = this.highlightedIndex();
|
||||
const el = document.getElementById(`quick-jump-agent-${idx}`);
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// ============================================================================
|
||||
// Task Progress Bar — Barrel Export
|
||||
// CUB-44
|
||||
// ============================================================================
|
||||
|
||||
export { TaskProgressBarComponent } from './task-progress-bar.component';
|
||||
@@ -1,18 +0,0 @@
|
||||
<!-- Task Progress Bar: determinate progress with optional elapsed time -->
|
||||
<div class="task-progress-bar">
|
||||
<!-- Info row: percentage + optional elapsed -->
|
||||
<div class="task-progress-bar__info">
|
||||
<span class="task-progress-bar__percent">{{ clampedProgress }}%</span>
|
||||
<span *ngIf="showElapsed" class="task-progress-bar__elapsed">
|
||||
{{ elapsedText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Angular Material determinate progress bar -->
|
||||
<mat-progress-bar
|
||||
class="task-progress-bar__bar"
|
||||
mode="determinate"
|
||||
[value]="clampedProgress"
|
||||
aria-label="Task progress"
|
||||
></mat-progress-bar>
|
||||
</div>
|
||||
@@ -1,77 +0,0 @@
|
||||
// ============================================================================
|
||||
// Task Progress Bar — Tactical Dark Theme Styling
|
||||
// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light
|
||||
// for track background, mapped to the Control Center's M3 dark tokens.
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Container
|
||||
// ---------------------------------------------------------------------------
|
||||
.task-progress-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Info row: percentage label + elapsed time
|
||||
// ---------------------------------------------------------------------------
|
||||
.task-progress-bar__info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-progress-bar__percent {
|
||||
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--cc-on-surface, #E2E8F0);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.task-progress-bar__elapsed {
|
||||
font-family: var(--cc-font-mono, 'Roboto Mono', monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface-variant, #8A9BB0);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Material Progress Bar Overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map the spec's --color-primary and --color-surface-light to the Control
|
||||
// Center's actual theme tokens. This ensures the bar uses the tactical dark
|
||||
// palette while respecting the spec's variable naming.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.task-progress-bar__bar {
|
||||
// Override the track (background) to use the surface container
|
||||
--mat-progress-bar-track-height: 6px;
|
||||
--mat-progress-bar-active-indicator-height: 6px;
|
||||
|
||||
// Bar fill color: primary (cyan/sky blue per tactical dark theme)
|
||||
--mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8));
|
||||
|
||||
// Track background: surface container (dark slate)
|
||||
--mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027));
|
||||
|
||||
// Border radius for a softer bar
|
||||
border-radius: 3px;
|
||||
|
||||
// Smooth transition on value changes
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// Rounded ends on the progress bar fill
|
||||
:host ::ng-deep .mdc-linear-progress__bar-inner {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Rounded track background
|
||||
:host ::ng-deep .mdc-linear-progress__track {
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// ============================================================================
|
||||
// Task Progress Bar Component
|
||||
// Per CUB-44: Determinate progress bar with optional elapsed time display.
|
||||
// Uses Angular Material mat-progress-bar in determinate mode with tactical
|
||||
// dark theme styling via CSS custom properties.
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
/**
|
||||
* Displays a determinate progress bar with an optional elapsed time indicator.
|
||||
*
|
||||
* Usage:
|
||||
* ```html
|
||||
* <app-task-progress-bar [progress]="65" />
|
||||
* <app-task-progress-bar [progress]="42" [showElapsed]="true" />
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-task-progress-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatProgressBarModule],
|
||||
templateUrl: './task-progress-bar.component.html',
|
||||
styleUrl: './task-progress-bar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TaskProgressBarComponent implements OnInit, OnDestroy {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Current progress percentage (0–100). Required. */
|
||||
@Input({ required: true })
|
||||
progress!: number;
|
||||
|
||||
/** Whether to show elapsed time next to the percentage. Defaults to false. */
|
||||
@Input()
|
||||
showElapsed = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Timestamp when the component initialized — used for elapsed calculation. */
|
||||
startTime = Date.now();
|
||||
|
||||
/** Formatted elapsed time string, e.g. "2m 15s ago". */
|
||||
elapsedText = '';
|
||||
|
||||
/** Interval timer for updating the elapsed display. */
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updateElapsed();
|
||||
|
||||
if (this.showElapsed) {
|
||||
// Update elapsed time every second
|
||||
this.timer = setInterval(() => {
|
||||
this.updateElapsed();
|
||||
this.cdr.markForCheck();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Clamp progress to 0–100 for safety. */
|
||||
get clampedProgress(): number {
|
||||
return Math.max(0, Math.min(100, this.progress ?? 0));
|
||||
}
|
||||
|
||||
/** Recalculate the elapsed time string. */
|
||||
private updateElapsed(): void {
|
||||
const elapsedMs = Date.now() - this.startTime;
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes > 0) {
|
||||
this.elapsedText = `${minutes}m ${seconds}s ago`;
|
||||
} else {
|
||||
this.elapsedText = `${seconds}s ago`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// ============================================================================
|
||||
// OpenClaw Control Center — Design System Barrel Export
|
||||
// ============================================================================
|
||||
// Import everything from '@app/design' for convenient access.
|
||||
//
|
||||
// Usage:
|
||||
// import { CcTokens, CcThemeService, CcCssProps } from '@app/design';
|
||||
// ============================================================================
|
||||
|
||||
export * from './tokens';
|
||||
export * from './theme.service';
|
||||
@@ -1,151 +0,0 @@
|
||||
// ============================================================================
|
||||
// OpenClaw Control Center — Theme Service
|
||||
// ============================================================================
|
||||
// Angular service providing programmatic access to design tokens, theme
|
||||
// mode switching (dark/light), and runtime CSS custom property manipulation.
|
||||
//
|
||||
// Usage:
|
||||
// constructor(private theme: CcThemeService) {}
|
||||
//
|
||||
// // Read a token
|
||||
// const primary = this.theme.getToken('--cc-color-primary');
|
||||
//
|
||||
// // Set a token at runtime
|
||||
// this.theme.setToken('--cc-color-primary', '#00ff00');
|
||||
//
|
||||
// // Toggle theme
|
||||
// this.theme.setMode('light');
|
||||
// ============================================================================
|
||||
|
||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||
import { CcCssProps, getStatusColor, setCssToken, getCssToken } from './tokens';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme Mode Types
|
||||
// ---------------------------------------------------------------------------
|
||||
export type ThemeMode = 'dark' | 'light';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Light theme overrides (future use)
|
||||
// ---------------------------------------------------------------------------
|
||||
const LIGHT_THEME_OVERRIDES: Record<string, string> = {
|
||||
// Surface tokens
|
||||
'--cc-surface-darkest': '#F8FAFC',
|
||||
'--cc-surface-dark': '#FFFFFF',
|
||||
'--cc-surface-medium': '#F1F5F9',
|
||||
'--cc-surface-light': '#E2E8F0',
|
||||
'--cc-surface-lighter': '#CBD5E1',
|
||||
|
||||
// On-surface tokens
|
||||
'--cc-on-surface': '#0F172A',
|
||||
'--cc-on-surface-variant': '#475569',
|
||||
'--cc-on-surface-muted': '#94A3B8',
|
||||
|
||||
// Border
|
||||
'--cc-surface-lighter-alt': '#E2E8F0',
|
||||
|
||||
// M3 system overrides for light
|
||||
'--mat-sys-surface': '#FFFFFF',
|
||||
'--mat-sys-surface-container': '#F1F5F9',
|
||||
'--mat-sys-surface-container-high': '#E2E8F0',
|
||||
'--mat-sys-on-surface': '#0F172A',
|
||||
'--mat-sys-on-surface-variant': '#475569',
|
||||
'--mat-sys-outline': '#CBD5E1',
|
||||
'--mat-sys-background': '#F8FAFC',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark theme (matches the SCSS defaults)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DARK_THEME_OVERRIDES: Record<string, string> = {
|
||||
'--cc-surface-darkest': '#0D0F12',
|
||||
'--cc-surface-dark': '#13161A',
|
||||
'--cc-surface-medium': '#1C2027',
|
||||
'--cc-surface-light': '#252B33',
|
||||
'--cc-surface-lighter': '#2D3748',
|
||||
|
||||
'--cc-on-surface': '#E2E8F0',
|
||||
'--cc-on-surface-variant': '#8A9BB0',
|
||||
'--cc-on-surface-muted': '#64748B',
|
||||
|
||||
'--mat-sys-surface': '#13161A',
|
||||
'--mat-sys-surface-container': '#1C2027',
|
||||
'--mat-sys-surface-container-high': '#252B33',
|
||||
'--mat-sys-on-surface': '#E2E8F0',
|
||||
'--mat-sys-on-surface-variant': '#8A9BB0',
|
||||
'--mat-sys-outline': '#2D3748',
|
||||
'--mat-sys-background': '#0D0F12',
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CcThemeService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signals for reactive theme state
|
||||
// ---------------------------------------------------------------------------
|
||||
private readonly _mode = signal<ThemeMode>(
|
||||
(localStorage.getItem('cc-theme') as ThemeMode) ?? 'dark'
|
||||
);
|
||||
|
||||
/** Current theme mode */
|
||||
readonly mode = this._mode.asReadonly();
|
||||
|
||||
/** Computed: is the current mode dark? */
|
||||
readonly isDark = computed(() => this._mode() === 'dark');
|
||||
|
||||
/** Computed: is the current mode light? */
|
||||
readonly isLight = computed(() => this._mode() === 'light');
|
||||
|
||||
constructor() {
|
||||
// Apply theme on init and whenever mode changes
|
||||
effect(() => {
|
||||
this.applyTheme(this._mode());
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Set the theme mode and persist to localStorage */
|
||||
setMode(mode: ThemeMode): void {
|
||||
this._mode.set(mode);
|
||||
localStorage.setItem('cc-theme', mode);
|
||||
}
|
||||
|
||||
/** Toggle between dark and light mode */
|
||||
toggle(): void {
|
||||
this.setMode(this._mode() === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
/** Read a CSS custom property from the document root */
|
||||
getToken(property: string): string {
|
||||
return getCssToken(property);
|
||||
}
|
||||
|
||||
/** Set a CSS custom property on the document root */
|
||||
setToken(property: string, value: string): void {
|
||||
setCssToken(property, value);
|
||||
}
|
||||
|
||||
/** Get status color set by agent status */
|
||||
getStatusColors(status: string): { fg: string; bg: string; border: string } {
|
||||
return getStatusColor(status);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Apply a theme mode by setting all CSS custom properties */
|
||||
private applyTheme(mode: ThemeMode): void {
|
||||
const overrides = mode === 'dark' ? DARK_THEME_OVERRIDES : LIGHT_THEME_OVERRIDES;
|
||||
|
||||
// Set color-scheme for native form controls
|
||||
document.documentElement.style.setProperty('color-scheme', mode);
|
||||
|
||||
// Apply all overrides
|
||||
for (const [prop, value] of Object.entries(overrides)) {
|
||||
document.documentElement.style.setProperty(prop, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
// ============================================================================
|
||||
// OpenClaw Control Center — Design Tokens (TypeScript)
|
||||
// ============================================================================
|
||||
// Typed representation of the design system tokens for programmatic access.
|
||||
// These mirror the SCSS tokens in styles/_tokens.scss and the CSS custom
|
||||
// properties emitted by styles/_css-properties.scss.
|
||||
//
|
||||
// Usage:
|
||||
// import { CcTokens } from '@app/design/tokens';
|
||||
// const primary = CcTokens.color.primary;
|
||||
// const surface = CcTokens.surface.dark;
|
||||
// ============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color Palette
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcColors = {
|
||||
primary: {
|
||||
50: '#ecfeff',
|
||||
100: '#cffafe',
|
||||
200: '#a5f3fc',
|
||||
300: '#67e8f9',
|
||||
400: '#22d3ee',
|
||||
500: '#38bdf8',
|
||||
600: '#0ea5e9',
|
||||
700: '#0284c7',
|
||||
800: '#0369a1',
|
||||
900: '#075985',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f0fdfa',
|
||||
100: '#ccfbf1',
|
||||
200: '#99f6e4',
|
||||
300: '#5eead4',
|
||||
400: '#2dd4bf',
|
||||
500: '#14b8a6',
|
||||
600: '#0d9488',
|
||||
700: '#0f766e',
|
||||
800: '#115e59',
|
||||
900: '#134e4a',
|
||||
},
|
||||
accent: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
200: '#ddd6fe',
|
||||
300: '#c4b5fd',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
800: '#5b21b6',
|
||||
900: '#4c1d95',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic Colors (Tactical Dark)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcSemanticColors = {
|
||||
surface: {
|
||||
darkest: '#0D0F12',
|
||||
dark: '#13161A',
|
||||
medium: '#1C2027',
|
||||
light: '#252B33',
|
||||
lighter: '#2D3748',
|
||||
},
|
||||
onSurface: {
|
||||
primary: '#E2E8F0',
|
||||
variant: '#8A9BB0',
|
||||
muted: '#64748B',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcStatusColors = {
|
||||
active: { fg: '#38bdf8', bg: 'rgba(56, 189, 248, 0.12)', border: 'rgba(56, 189, 248, 0.40)' },
|
||||
idle: { fg: '#2dd4bf', bg: 'rgba(45, 212, 191, 0.12)', border: 'rgba(45, 212, 191, 0.40)' },
|
||||
thinking: { fg: '#a78bfa', bg: 'rgba(167, 139, 250, 0.12)', border: 'rgba(167, 139, 250, 0.40)' },
|
||||
error: { fg: '#f87171', bg: 'rgba(248, 113, 113, 0.12)', border: 'rgba(248, 113, 113, 0.40)' },
|
||||
offline: { fg: '#64748b', bg: 'rgba(100, 116, 139, 0.12)', border: 'rgba(100, 116, 139, 0.40)' },
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience exports for component usage (CUB-20)
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Status colors — maps AgentStatus to hex values */
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
active: '#38BDF8',
|
||||
idle: '#2DD4BF',
|
||||
thinking: '#A78BFA',
|
||||
error: '#F87171',
|
||||
offline: '#64748B',
|
||||
};
|
||||
|
||||
/** Status background tints (12% opacity) */
|
||||
export const STATUS_BG_COLORS: Record<string, string> = {
|
||||
active: 'rgba(56, 189, 248, 0.12)',
|
||||
idle: 'rgba(45, 212, 191, 0.12)',
|
||||
thinking: 'rgba(167, 139, 250, 0.12)',
|
||||
error: 'rgba(248, 113, 113, 0.12)',
|
||||
offline: 'rgba(100, 116, 139, 0.12)',
|
||||
};
|
||||
|
||||
/** Surface overrides (CUB-20 convenience) */
|
||||
export const SURFACE = {
|
||||
background: '#0D0F12',
|
||||
surface: '#13161A',
|
||||
container: '#1C2027',
|
||||
containerHigh: '#252B33',
|
||||
onSurface: '#E2E8F0',
|
||||
onSurfaceVariant: '#8A9BB0',
|
||||
outline: '#2D3748',
|
||||
} as const;
|
||||
|
||||
/** Tactical Dark Mode color palette (CUB-20 convenience) */
|
||||
export const COLORS = {
|
||||
surface: '#0F172A',
|
||||
surfaceLight: '#1E293B',
|
||||
primary: '#38BDF8',
|
||||
secondary: '#2DD4BF',
|
||||
accent: '#A78BFA',
|
||||
danger: '#F87171',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#94A3B8',
|
||||
border: '#334155',
|
||||
} as const;
|
||||
|
||||
/** Layout constants (CUB-20 convenience) */
|
||||
export const LAYOUT = {
|
||||
navRailCollapsedWidth: 72,
|
||||
navRailExpandedWidth: 256,
|
||||
headerHeight: 64,
|
||||
bottomNavHeight: 80,
|
||||
cardBorderRadius: 16,
|
||||
cardMinWidth: 320,
|
||||
cardGap: 16,
|
||||
cardPadding: 20,
|
||||
sectionPadding: 24,
|
||||
spacingUnit: 8,
|
||||
} as const;
|
||||
|
||||
/** Breakpoints (px) (CUB-20 convenience) */
|
||||
export const BREAKPOINTS = {
|
||||
mobile: 599,
|
||||
tablet: 1023,
|
||||
desktop: 1024,
|
||||
} as const;
|
||||
|
||||
/** Channel icon mapping (CUB-20) */
|
||||
export const CHANNEL_ICONS: Record<string, string> = {
|
||||
telegram: 'telegram',
|
||||
slack: 'chat',
|
||||
discord: 'forum',
|
||||
whatsapp: 'chat',
|
||||
webchat: 'language',
|
||||
email: 'email',
|
||||
mqtt: 'sensors',
|
||||
};
|
||||
|
||||
/** Human-readable status labels (CUB-20) */
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
idle: 'Idle',
|
||||
thinking: 'Thinking…',
|
||||
error: 'Error',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcTypography = {
|
||||
fontFamily: {
|
||||
brand: "'Inter, Roboto, sans-serif'",
|
||||
body: "'Inter, Roboto, sans-serif'",
|
||||
mono: "'Roboto Mono, Cascadia Code, Fira Code, monospace'",
|
||||
},
|
||||
size: {
|
||||
displayLarge: '57px',
|
||||
displayMedium: '45px',
|
||||
displaySmall: '36px',
|
||||
headlineLarge: '32px',
|
||||
headlineMedium: '28px',
|
||||
headlineSmall: '24px',
|
||||
titleLarge: '22px',
|
||||
titleMedium: '16px',
|
||||
titleSmall: '14px',
|
||||
bodyLarge: '16px',
|
||||
bodyMedium: '14px',
|
||||
bodySmall: '12px',
|
||||
labelLarge: '14px',
|
||||
labelMedium: '12px',
|
||||
labelSmall: '11px',
|
||||
},
|
||||
weight: {
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
bold: 600,
|
||||
heavy: 700,
|
||||
},
|
||||
lineHeight: {
|
||||
tight: '1.2',
|
||||
normal: '1.5',
|
||||
relaxed: '1.6',
|
||||
},
|
||||
letterSpacing: {
|
||||
tight: '-0.01em',
|
||||
normal: '0em',
|
||||
wide: '0.02em',
|
||||
mono: '0.05em',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spacing (4px grid)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcSpacing = {
|
||||
0: '0px',
|
||||
1: '4px',
|
||||
2: '8px',
|
||||
3: '12px',
|
||||
4: '16px',
|
||||
5: '20px',
|
||||
6: '24px',
|
||||
7: '28px',
|
||||
8: '32px',
|
||||
9: '36px',
|
||||
10: '40px',
|
||||
12: '48px',
|
||||
14: '56px',
|
||||
16: '64px',
|
||||
20: '80px',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcLayout = {
|
||||
navRailCollapsedWidth: '72px',
|
||||
navRailExpandedWidth: '256px',
|
||||
headerHeight: '64px',
|
||||
bottomNavHeight: '80px',
|
||||
cardBorderRadius: '16px',
|
||||
cardMinWidth: '320px',
|
||||
badgeHeight: '24px',
|
||||
badgeBorderRadius: '12px',
|
||||
statusDotSize: '10px',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Breakpoints (M3 canonical)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcBreakpoints = {
|
||||
compact: 599,
|
||||
medium: 767,
|
||||
expanded: 1023,
|
||||
large: 1439,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Border Radius
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcRadius = {
|
||||
none: '0px',
|
||||
xs: '4px',
|
||||
sm: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '24px',
|
||||
full: '9999px',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shadows (M3 elevation)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcShadows = {
|
||||
level0: 'none',
|
||||
level1: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3)',
|
||||
level2: '0 2px 6px 0 rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
|
||||
level3: '0 4px 12px 0 rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.3)',
|
||||
level4: '0 8px 24px 0 rgba(0, 0, 0, 0.3), 0 8px 16px -8px rgba(0, 0, 0, 0.3)',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcMotion = {
|
||||
duration: {
|
||||
instant: 0,
|
||||
fast: 100,
|
||||
short: 150,
|
||||
medium: 200,
|
||||
standard: 300,
|
||||
long: 500,
|
||||
},
|
||||
easing: {
|
||||
standard: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
decelerate: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
accelerate: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
sharp: 'cubic-bezier(0.4, 0, 0.6, 1)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcA11y = {
|
||||
focusRing: {
|
||||
width: '2px',
|
||||
offset: '2px',
|
||||
color: '#38bdf8',
|
||||
style: 'solid',
|
||||
},
|
||||
minTouchTarget: 48,
|
||||
minBodyFont: 16,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregate token object for convenient access
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcTokens = {
|
||||
color: CcColors,
|
||||
semantic: CcSemanticColors,
|
||||
status: CcStatusColors,
|
||||
typography: CcTypography,
|
||||
spacing: CcSpacing,
|
||||
layout: CcLayout,
|
||||
breakpoints: CcBreakpoints,
|
||||
radius: CcRadius,
|
||||
shadows: CcShadows,
|
||||
motion: CcMotion,
|
||||
a11y: CcA11y,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSS Custom Property Names
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CcCssProps = {
|
||||
// Color
|
||||
colorPrimary: '--cc-color-primary',
|
||||
colorSecondary: '--cc-color-secondary',
|
||||
colorAccent: '--cc-color-accent',
|
||||
colorDanger: '--cc-color-danger',
|
||||
|
||||
// Surface
|
||||
surfaceDarkest: '--cc-surface-darkest',
|
||||
surfaceDark: '--cc-surface-dark',
|
||||
surfaceMedium: '--cc-surface-medium',
|
||||
surfaceLight: '--cc-surface-light',
|
||||
surfaceLighter: '--cc-surface-lighter',
|
||||
|
||||
// On-surface
|
||||
onSurface: '--cc-on-surface',
|
||||
onSurfaceVariant: '--cc-on-surface-variant',
|
||||
onSurfaceMuted: '--cc-on-surface-muted',
|
||||
|
||||
// Status
|
||||
statusActive: '--cc-status-active',
|
||||
statusIdle: '--cc-status-idle',
|
||||
statusThinking: '--cc-status-thinking',
|
||||
statusError: '--cc-status-error',
|
||||
statusOffline: '--cc-status-offline',
|
||||
statusActiveBg: '--cc-status-active-bg',
|
||||
statusIdleBg: '--cc-status-idle-bg',
|
||||
statusThinkingBg: '--cc-status-thinking-bg',
|
||||
statusErrorBg: '--cc-status-error-bg',
|
||||
statusOfflineBg: '--cc-status-offline-bg',
|
||||
statusActiveBorder: '--cc-status-active-border',
|
||||
statusIdleBorder: '--cc-status-idle-border',
|
||||
statusThinkingBorder: '--cc-status-thinking-border',
|
||||
statusErrorBorder: '--cc-status-error-border',
|
||||
statusOfflineBorder: '--cc-status-offline-border',
|
||||
|
||||
// Typography
|
||||
fontBrand: '--cc-font-brand',
|
||||
fontBody: '--cc-font-body',
|
||||
fontMono: '--cc-font-mono',
|
||||
|
||||
// Spacing
|
||||
spacing2: '--cc-spacing-2',
|
||||
spacing4: '--cc-spacing-4',
|
||||
spacing6: '--cc-spacing-6',
|
||||
spacing8: '--cc-spacing-8',
|
||||
spacing12: '--cc-spacing-12',
|
||||
spacing16: '--cc-spacing-16',
|
||||
|
||||
// Layout
|
||||
navRailCollapsed: '--cc-nav-rail-collapsed',
|
||||
navRailExpanded: '--cc-nav-rail-expanded',
|
||||
headerHeight: '--cc-header-height',
|
||||
bottomNavHeight: '--cc-bottom-nav-height',
|
||||
cardRadius: '--cc-card-radius',
|
||||
cardMinWidth: '--cc-card-min-width',
|
||||
|
||||
// Radius
|
||||
radiusNone: '--cc-radius-none',
|
||||
radiusXs: '--cc-radius-xs',
|
||||
radiusSm: '--cc-radius-sm',
|
||||
radiusMd: '--cc-radius-md',
|
||||
radiusLg: '--cc-radius-lg',
|
||||
radiusXl: '--cc-radius-xl',
|
||||
radiusFull: '--cc-radius-full',
|
||||
|
||||
// Shadows
|
||||
shadow0: '--cc-shadow-0',
|
||||
shadow1: '--cc-shadow-1',
|
||||
shadow2: '--cc-shadow-2',
|
||||
shadow3: '--cc-shadow-3',
|
||||
shadow4: '--cc-shadow-4',
|
||||
|
||||
// Motion
|
||||
durationFast: '--cc-duration-fast',
|
||||
durationShort: '--cc-duration-short',
|
||||
durationMedium: '--cc-duration-medium',
|
||||
durationStandard: '--cc-duration-standard',
|
||||
durationLong: '--cc-duration-long',
|
||||
easingStandard: '--cc-easing-standard',
|
||||
easingDecelerate: '--cc-easing-decelerate',
|
||||
easingAccelerate: '--cc-easing-accelerate',
|
||||
|
||||
// Accessibility
|
||||
focusWidth: '--cc-focus-width',
|
||||
focusOffset: '--cc-focus-offset',
|
||||
focusColor: '--cc-focus-color',
|
||||
touchMin: '--cc-touch-min',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: Read a CSS custom property from the document
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getCssToken(propertyName: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(propertyName).trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: Set a CSS custom property on the document root
|
||||
// ---------------------------------------------------------------------------
|
||||
export function setCssToken(propertyName: string, value: string): void {
|
||||
document.documentElement.style.setProperty(propertyName, value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: Get status color by agent status type
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getStatusColor(status: string): { fg: string; bg: string; border: string } {
|
||||
const statusMap: Record<string, { fg: string; bg: string; border: string }> = CcStatusColors;
|
||||
return statusMap[status] ?? CcStatusColors.offline;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { LongPressDirective } from './long-press.directive';
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
Input,
|
||||
} from '@angular/core';
|
||||
|
||||
// ============================================================================
|
||||
// Long-Press Directive — CUB-26
|
||||
// Emits after a sustained press (500ms default).
|
||||
// Used on agent cards to bypass the drawer and open Session Log directly.
|
||||
// ============================================================================
|
||||
|
||||
@Directive({
|
||||
selector: '[appLongPress]',
|
||||
standalone: true,
|
||||
host: {
|
||||
'(mousedown)': 'onMouseDown($event)',
|
||||
'(mouseup)': 'onMouseUp()',
|
||||
'(mouseleave)': 'onMouseLeave()',
|
||||
'(touchstart)': 'onTouchStart($event)',
|
||||
'(touchend)': 'onTouchEnd()',
|
||||
'(touchmove)': 'onTouchMove()',
|
||||
'(contextmenu)': 'onContextMenu($event)',
|
||||
},
|
||||
})
|
||||
export class LongPressDirective implements OnDestroy {
|
||||
/** Duration in ms before a press counts as a long press. */
|
||||
@Input() appLongPressDuration = 500;
|
||||
|
||||
/** Emits when a long press is detected. Payload is the original event. */
|
||||
@Output() readonly appLongPress = new EventEmitter<MouseEvent | TouchEvent>();
|
||||
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private isLongPress = false;
|
||||
|
||||
onMouseDown(event: MouseEvent): void {
|
||||
this.isLongPress = false;
|
||||
this.timer = setTimeout(() => {
|
||||
this.isLongPress = true;
|
||||
this.appLongPress.emit(event);
|
||||
}, this.appLongPressDuration);
|
||||
}
|
||||
|
||||
onMouseUp(): void {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
onMouseLeave(): void {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
onTouchStart(event: TouchEvent): void {
|
||||
this.isLongPress = false;
|
||||
this.timer = setTimeout(() => {
|
||||
this.isLongPress = true;
|
||||
this.appLongPress.emit(event);
|
||||
}, this.appLongPressDuration);
|
||||
}
|
||||
|
||||
onTouchEnd(): void {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
onTouchMove(): void {
|
||||
// Cancel on touch move (finger moved)
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
onContextMenu(event: MouseEvent): void {
|
||||
// Prevent native context menu on long press
|
||||
if (this.isLongPress) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<nav class="bottom-nav" aria-label="Bottom navigation">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="bottom-nav__item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="bottom-nav__item--active"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-label]="dest.label"
|
||||
[attr.aria-current]="rla.isActive ? 'page' : null"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrapper">
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user