Compare commits

...

28 Commits

Author SHA1 Message Date
devops e0fc305832 chore: bump version to v0.2.34 [skip ci] 2026-06-09 22:31:24 +00:00
developer c120155170 fix: GatewayClient robuste Response-Parsing + Isolierte Try/Catch
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- ExtractToolData: unwraps content[0].text JSON + details
- GetStatusAsync: separates try/catch per step (ping, session, agents, queue)
- GetAgentsAsync: parses gateway agents[] array from sessions_list
- GetQueueAsync: extracts from cron response data.jobs
- gatewayOk no longer overridden by downstream tool errors
2026-06-10 00:30:36 +02:00
devops 0241130c2f chore: bump version to v0.2.33 [skip ci] 2026-06-09 22:27:10 +00:00
developer 889af65ae7 fix: GatewayClient Tool-Namen + Response-Unwrapping
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- sub_agents_list → subagents (action: list)
- cron_list → cron (action: list)
- Ping / → /health
- Unwrap {ok, result} envelope in InvokeToolAsync
2026-06-10 00:26:22 +02:00
devops bdd75c9224 chore: bump version to v0.2.32 [skip ci] 2026-06-09 22:21:37 +00:00
developer f707dceb98 feat: Dashboard Backend Proxy – OpenClaw Gateway Integration
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- DashboardController: /api/dashboard/status, agents, operations, chat/send, chat/messages, queue
- OpenClawGatewayClient: typed HttpClient mit Gateway tools/invoke
- Dashboard DTOs: DashboardAgentInfo, ChatRequest, ChatResponse, FeedEntry, QueueItem
- Gateway auth: Bearer-Password via Integrations:OpenClaw:Password
- Gateway-Down → graceful degradation (HTTP 200, leere Daten)
- Build: 0 errors, Tests: 3/3 passed
2026-06-10 00:20:49 +02:00
devops 96a44233c0 chore: bump version to v0.2.31 [skip ci] 2026-06-09 21:47:39 +00:00
developer 191cb5cbd2 fix: FeedDetailModal v-if fehlte – immer sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- v-if='modelValue' auf Overlay-Div
- Modal nur noch an wenn showDetailModal=true
- Overlay-Klick + X-Button schließen korrekt
2026-06-09 23:46:51 +02:00
devops 12e629432c chore: bump version to v0.2.30 [skip ci] 2026-06-09 21:43:48 +00:00
developer 47f0f1d786 feat: Iris Chat Expand-Button + Modal
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Maximize2-Button im Chat-Header
- Expand-Modal (700px, 70vh) mit vollem Chat
- Teleport-Overlay, X-Close, Overlay-Klick
- Separater chatModalListRef für Modal-Scrolling
2026-06-09 23:42:59 +02:00
devops bf60b8b064 chore: bump version to v0.2.29 [skip ci] 2026-06-09 21:41:01 +00:00
developer b8498f47bb [Reviewer] Dashboard Review: Interface-Bereinigung, SVG-Composable-Extraktion, Komponenten-Renaming, CSS-Fix
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- AgentNodeData: Remove redundant fields task/runtime (dup of currentTask/runtimeSeconds)
- useTeamNetworkSvg: Extract SVG layout, path computation + pulse animation from TeamNetwork
- TeamNetwork: Use AgentNodeData type, fix undefined pulseElements2/storePulseRef2, remove unused props
- Rename MissionCard.vue → TaskCard.vue (matches actual usage)
- Extract FeedDetailModal from OperationsFeed (eliminates :global() CSS conflict with AgentModal)
- DashboardView: Fix type import path (../../ → ../), remove dead TeamNetwork props
- AgentModal: Remove unused thinkingStreamRef template ref

Build: vue-tsc --noEmit 0 errors, vite build ✓
2026-06-09 23:38:23 +02:00
devops f037aa2eeb chore: bump version to v0.2.28 [skip ci] 2026-06-09 21:30:00 +00:00
developer e6520fc26d fix: Queue → Chat Queue umbenannt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-09 23:29:11 +02:00
devops c9d8852609 chore: bump version to v0.2.27 [skip ci] 2026-06-09 21:22:19 +00:00
developer 11e9a257a1 fix: AgentNodeData um tags, task, runtime erweitert – Cards wieder sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
- Interface: tags:string[], task:string, runtime:string, hero?:boolean
- Alle 5 Agenten mit Tags, Task, Runtime, Hero befüllt
2026-06-09 23:21:31 +02:00
devops ead202ad8b chore: bump version to v0.2.26 [skip ci] 2026-06-09 21:16:23 +00:00
developer effc86e15b feat: LLM Model + Glassmorphism + Doppel-Pulse + Task-Board
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Agent Cards: aktives LLM Model unter Runtime
- Glassmorphism: rgba-BG + backdrop-filter:blur
- Bézier-Linien: 2 Pulse pro Verbindung (Offset 50%)
- TaskCard: 'Zum Task Board' Button mit Pfeil
2026-06-09 23:15:33 +02:00
devops 0f9809e423 chore: bump version to v0.2.25 [skip ci] 2026-06-09 21:01:16 +00:00
developer c2736d20c1 feat: Cards, Offene Aufgaben, Feed – Komplettumbau
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
TeamNetwork: Footer→Arrow, Current Task+Runtime inline
Missions→Offene Aufgaben (TaskCard) mit +New Task, Iris/Bao-Quelle
OperationsFeed: Text-Wrap, 5 Items, Mehr-Button→Tag-Navigation-Modal
2026-06-09 23:00:26 +02:00
devops 084cff4fe6 chore: bump version to v0.2.24 [skip ci] 2026-06-09 20:45:02 +00:00
developer ef3fc6039e fix: Modal-Layout – Dots bei Current Task + Reihenfolge + Timestamps
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Dots (blau/violett) rechts von Current Task, unterschiedlich pulsierend
- Reihenfolge: Current Task → Live Thinking → Goal → Working Feed
- Link-Button enger am Namen (margin-left:4px, opacity 0.4)
- Working Feed mit Timestamps (step.time)
- Workload-Tag aus Footer entfernt
2026-06-09 22:44:13 +02:00
devops 3599513128 chore: bump version to v0.2.23 [skip ci] 2026-06-09 20:36:05 +00:00
developer 7dd8f53f2f fix: Dots rechts vom Thinking-Text + Glow intensiver + Link-Button
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- Dots (blau+lila) inline rechts vom aktuellen Thinking-Eintrag, rotierend
- Orbiter-Rahmen entfernt
- Panel-Glow verstärkt (border 0.5, shadow 24px+40px)
- ExternalLink-Button rechts vom Agent-Namen → /agents/{id}
2026-06-09 22:35:15 +02:00
devops 90bb7251e3 chore: bump version to v0.2.22 [skip ci] 2026-06-09 20:31:44 +00:00
developer e57bef95e5 fix: mehr Abstand Iris↔Grid + Linien enger gebündelt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- gap: 32px → 64px (doppelter Vertikalraum)
- startX: 0.30+0.40 → 0.38+0.24 (enger unter Iris)
- cp1y: startY+40 → startY+70 (tiefer vor Spread)
- cp2x: ±50 → ±35 (sanftere Card-Annäherung)
2026-06-09 22:30:55 +02:00
devops 71b4465595 chore: bump version to v0.2.21 [skip ci] 2026-06-09 20:28:41 +00:00
developer 9b63e5368e feat: Live Thinking Panel im AgentModal
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Scrollbarer Thinking-Stream (slide-in von links)
- Pulsierender Rahmen mit Glow-Effekt
- Blau+Violett Dots rotieren im Uhrzeigersinn
- thinkingStream in AgentNodeData + Beispieldaten für alle 5 Agenten
2026-06-09 22:27:53 +02:00
16 changed files with 1964 additions and 597 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.20 0.2.34
+208
View File
@@ -0,0 +1,208 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
public class DashboardController(OpenClawGatewayClient gateway, ILogger<DashboardController> logger)
: ControllerBase
{
/// <summary>
/// Gateway health + session_status + subagents count.
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
/// </summary>
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
/// <summary>
/// Returns all agents with their current status.
/// Combines sessions_list + sub_agents_list.
/// </summary>
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return new List<DashboardAgentInfo>();
}
}
/// <summary>
/// Returns the latest assistant messages (operations/feed) from the Iris session.
/// Filtered to role == "assistant" — those are the work feed entries.
/// </summary>
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations([FromQuery] int limit = 20)
{
try
{
var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100));
var feed = new List<FeedEntry>();
foreach (var msg in messages)
{
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(msg.Content))
continue;
// Parse timestamp for display-friendly "time ago"
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
// Extract a short agent indicator and action from content
var (agent, action) = ExtractAgentAction(msg.Content);
feed.Add(new FeedEntry(agent, action, msg.Timestamp, timeAgo));
}
return feed;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return new List<FeedEntry>();
}
}
/// <summary>
/// Send a chat message to the Iris session.
/// </summary>
[HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required");
try
{
var sessionKey = string.IsNullOrWhiteSpace(request.SessionKey)
? "iris"
: request.SessionKey.Trim();
return await gateway.SendChatMessageAsync(sessionKey, request.Message.Trim());
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
}
/// <summary>
/// Returns chat messages (user + assistant only, not tool messages).
/// </summary>
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
{
try
{
var key = string.IsNullOrWhiteSpace(sessionKey) ? "iris" : sessionKey.Trim();
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
// Filter: only user and assistant messages (exclude tool/system)
return messages
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard messages fetch failed");
return new List<MessageEntry>();
}
}
/// <summary>
/// Returns the cron queue / pending tasks.
/// </summary>
[HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue()
{
try
{
return await gateway.GetQueueAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return new List<QueueItem>();
}
}
// ========== Helpers ==========
private static DateTimeOffset ParseTimestamp(string timestamp)
{
if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt))
return dt;
return DateTimeOffset.UtcNow;
}
private static string FormatTimeAgo(DateTimeOffset ts)
{
var diff = DateTimeOffset.UtcNow - ts;
if (diff.TotalMinutes < 1) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
return ts.ToString("MMM dd");
}
private static (string Agent, string Action) ExtractAgentAction(string content)
{
// Take first line or first ~80 chars as the action summary
var firstLine = content.Split('\n', 2)[0].Trim();
var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine;
// Try to identify which agent this came from
var agent = "Iris";
foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" })
{
var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var after = content[(idx + marker.Length)..].TrimStart();
var end = after.IndexOfAny(['\n', '\r', ',', '.']);
var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim();
if (!string.IsNullOrWhiteSpace(found) && found.Length < 30)
{
agent = found;
break;
}
}
}
// Try to find agent name at the start in brackets like [Agent: Iris]
if (agent == "Iris")
{
var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]");
if (bracketMatch.Success)
agent = bracketMatch.Groups[1].Value.Trim();
}
return (agent, summary);
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace Nexus.Api.Models;
public sealed record DashboardAgentInfo(
string Id,
string Name,
string Role,
string Model,
bool IsActive,
string? CurrentTask
);
public sealed record MessageEntry(
string Role,
string Content,
string Timestamp
);
public sealed record ChatRequest(
string Message,
string? SessionKey
);
public sealed record ChatResponse(
bool Ok,
string? Reply,
string? Error
);
public sealed record FeedEntry(
string Agent,
string Action,
string Timestamp,
string Time
);
public sealed record DashboardStatus(
bool GatewayOk,
string IrisStatus,
int ActiveAgents,
int PendingTasks
);
public sealed record QueueItem(
string Id,
string Name,
string Status
);
+7
View File
@@ -112,6 +112,13 @@ builder.Services.AddHttpClient("gateway", client =>
client.Timeout = TimeSpan.FromSeconds(5); client.Timeout = TimeSpan.FromSeconds(5);
}); });
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
});
// --- Application Services --- // --- Application Services ---
builder.Services.AddTransient<ModelRoutingService>(); builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
+298
View File
@@ -0,0 +1,298 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private string? GetPassword()
{
var password = configuration["Integrations:OpenClaw:Password"];
if (string.IsNullOrWhiteSpace(password))
password = configuration["Integrations:OpenClaw:Token"];
return string.IsNullOrWhiteSpace(password) ? null : password;
}
private void ApplyAuth(HttpRequestMessage request)
{
var password = GetPassword();
if (password is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", password);
}
public async Task<JsonNode?> InvokeToolAsync(string tool, object? args = null)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke");
ApplyAuth(request);
var body = new Dictionary<string, object?> { ["tool"] = tool };
if (args is not null)
body["args"] = args;
request.Content = JsonContent.Create(body);
using var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json))
return null;
var node = JsonNode.Parse(json);
// Unwrap the { ok: true, result: ... } envelope
if (node?["ok"]?.GetValue<bool>() == true && node["result"] is not null)
return node["result"];
return node;
}
catch
{
return null;
}
}
/// <summary>
/// Extracts useful data from a tool response that may embed JSON in content[0].text.
/// Returns the unwrapped "details" object if present, otherwise the raw result.
/// </summary>
private static JsonNode? ExtractToolData(JsonNode? result)
{
if (result is null) return null;
// Some tools return { details: {...}, content: [...] }
if (result["details"] is JsonNode details && details is not JsonValue)
return details;
// Some tools wrap in content[0].text as JSON string
if (result["content"] is JsonArray content && content.Count > 0)
{
var text = content[0]?["text"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(text))
{
try { return JsonNode.Parse(text); }
catch { /* fall through */ }
}
}
return result;
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
var agents = new List<DashboardAgentInfo>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Get sessions list
var sessionsResult = await InvokeToolAsync("sessions_list");
var sessionsData = ExtractToolData(sessionsResult);
if (sessionsData is JsonObject so)
{
// sessions_list returns { agents: [...], sessions: {...} }
// Try agents array first (from gateway call)
if (so["agents"] is JsonArray agentArray)
{
foreach (var a in agentArray)
{
if (a is null) continue;
var id = a["agentId"]?.GetValue<string>() ?? "";
if (string.IsNullOrWhiteSpace(id) || !seenIds.Add(id)) continue;
var name = a["name"]?.GetValue<string>() ?? id;
var model = "GPT-5.4"; // default, overridden if available
var isActive = false;
var currentTask = (string?)null;
// Check if agent has recent sessions (active indicator)
if (a["sessions"] is JsonObject sess && sess["recent"] is JsonArray recent && recent.Count > 0)
{
var age = recent[0]?["age"]?.GetValue<long>() ?? long.MaxValue;
isActive = age < 300_000; // active if session < 5 min ago
currentTask = recent[0]?["key"]?.GetValue<string>();
}
agents.Add(new DashboardAgentInfo(id, name, DeriveRole(id), model, isActive, currentTask));
}
}
}
// Also get subagents list
var subResult = await InvokeToolAsync("subagents", new { action = "list" });
var subData = ExtractToolData(subResult);
if (subData is JsonArray subArray)
{
foreach (var s in subArray)
{
if (s is null) continue;
var id = s["id"]?.GetValue<string>() ?? s["agentId"]?.GetValue<string>() ?? "";
if (string.IsNullOrWhiteSpace(id) || !seenIds.Add(id)) continue;
var name = s["name"]?.GetValue<string>() ?? id;
var status = s["status"]?.GetValue<string>() ?? "";
var isActive = status is "active" or "running";
var currentTask = s["task"]?.GetValue<string>() ?? s["currentTask"]?.GetValue<string>();
agents.Add(new DashboardAgentInfo(id, name, DeriveRole(id), "", isActive, currentTask));
}
}
return agents;
}
public async Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0)
{
try
{
var result = await InvokeToolAsync("sessions_history", new { sessionKey, limit, offset });
if (result is null) return new List<MessageEntry>();
var messages = new List<MessageEntry>();
var array = result as JsonArray ?? result.AsArray();
if (array is null) return messages;
foreach (var msg in array)
{
if (msg is null) continue;
var role = msg["role"]?.GetValue<string>() ?? "";
var content = msg["content"]?.GetValue<string>() ?? "";
var timestamp = msg["timestamp"]?.GetValue<string>()
?? msg["ts"]?.GetValue<string>()
?? msg["createdAt"]?.GetValue<string>()
?? DateTimeOffset.UtcNow.ToString("o");
messages.Add(new MessageEntry(role, content, timestamp));
}
return messages;
}
catch
{
return new List<MessageEntry>();
}
}
public async Task<ChatResponse> SendChatMessageAsync(string sessionKey, string message)
{
try
{
var result = await InvokeToolAsync("sessions_send", new { sessionKey, message });
if (result is null) return new ChatResponse(false, null, "Gateway nicht erreichbar");
var ok = result["ok"]?.GetValue<bool>() ?? false;
var reply = result["reply"]?.GetValue<string>()
?? result["response"]?.GetValue<string>()
?? result["content"]?[0]?["text"]?.GetValue<string>();
var error = result["error"]?.GetValue<string>();
return new ChatResponse(ok, reply, error);
}
catch (Exception ex)
{
return new ChatResponse(false, null, $"Fehler: {ex.Message}");
}
}
public async Task<List<QueueItem>> GetQueueAsync()
{
try
{
var result = await InvokeToolAsync("cron", new { action = "list" });
var data = ExtractToolData(result);
if (data is null) return new List<QueueItem>();
var items = new List<QueueItem>();
var jobs = data["jobs"] as JsonArray ?? data.AsArray();
if (jobs is null) return items;
foreach (var j in jobs)
{
if (j is null) continue;
var id = j["id"]?.GetValue<string>() ?? "";
var name = j["name"]?.GetValue<string>() ?? id;
var status = j["state"]?["lastStatus"]?.GetValue<string>()
?? j["status"]?.GetValue<string>()
?? "unknown";
items.Add(new QueueItem(id, name, status));
}
return items;
}
catch
{
return new List<QueueItem>();
}
}
public async Task<DashboardStatus> GetStatusAsync()
{
var gatewayOk = false;
var irisStatus = "Offline";
var activeAgents = 0;
var pendingTasks = 0;
// Step 1: Health check (no auth needed)
try
{
using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/health");
using var pingResponse = await httpClient.SendAsync(pingRequest);
gatewayOk = pingResponse.IsSuccessStatusCode;
}
catch
{
// gatewayOk stays false
}
if (gatewayOk)
{
// Step 2: Session status
try
{
var sessionResult = await InvokeToolAsync("session_status");
if (sessionResult is not null)
{
irisStatus = sessionResult["status"]?.GetValue<string>()
?? sessionResult["sessionKey"]?.GetValue<string>()
?? "Active";
}
}
catch { }
// Step 3: Active agents
try
{
var agents = await GetAgentsAsync();
activeAgents = agents.Count(a => a.IsActive);
}
catch { }
// Step 4: Queue items
try
{
var queue = await GetQueueAsync();
pendingTasks = queue.Count;
}
catch { }
}
return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks);
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"programmer" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"executor" => "Executor",
"researcher" => "Researcher",
"main" => "Assistant",
_ => "Custom"
};
}
+148 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { X } from '@lucide/vue' import { X, ExternalLink } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData' import type { AgentNodeData } from '../../composables/useDashboardData'
defineProps<{ defineProps<{
@@ -25,6 +25,9 @@ defineEmits<{
<div> <div>
<h2>{{ agent.name }}</h2> <h2>{{ agent.name }}</h2>
<span class="modal-role">{{ agent.role }}</span> <span class="modal-role">{{ agent.role }}</span>
<a :href="`/agents/${agent.id}`" class="agent-link-btn" title="Open agent config">
<ExternalLink :size="12" />
</a>
</div> </div>
</div> </div>
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close"> <button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
@@ -38,7 +41,34 @@ defineEmits<{
<!-- Current Task --> <!-- Current Task -->
<section class="modal-section"> <section class="modal-section">
<h3 class="section-label">Current Task</h3> <h3 class="section-label">Current Task</h3>
<p class="section-value">{{ agent.currentTask }}</p> <p class="section-value">
{{ agent.currentTask }}
<span class="thinking-dots">
<span class="thinking-dot blue"></span>
<span class="thinking-dot violet"></span>
</span>
</p>
</section>
<!-- Live Thinking -->
<section class="modal-section">
<h3 class="section-label">Live Thinking</h3>
<div class="thinking-panel">
<div class="thinking-stream">
<div
v-for="(msg, idx) in agent.thinkingStream"
:key="idx"
class="thinking-entry"
:style="{ animationDelay: `${idx * 0.05}s` }"
>
<span class="entry-time">{{ msg.time }}</span>
<span class="entry-text">{{ msg.text }}</span>
</div>
<div v-if="!agent.thinkingStream?.length" class="thinking-placeholder">
Waiting for thought stream...
</div>
</div>
</div>
</section> </section>
<!-- Goal + Progress --> <!-- Goal + Progress -->
@@ -66,7 +96,8 @@ defineEmits<{
class="work-step" class="work-step"
> >
<span class="step-dot"></span> <span class="step-dot"></span>
<span class="step-text">{{ step }}</span> <span class="step-time">{{ step.time }}</span>
<span class="step-text">{{ step.text }}</span>
</div> </div>
</div> </div>
</section> </section>
@@ -74,15 +105,6 @@ defineEmits<{
<!-- Footer Stats --> <!-- Footer Stats -->
<div class="modal-footer"> <div class="modal-footer">
<span class="footer-badge">Runtime: {{ runtime }}</span> <span class="footer-badge">Runtime: {{ runtime }}</span>
<span
class="footer-badge"
:style="{
color: agent.workload > 65 ? '#eab308' : '#22c55e',
borderColor: agent.workload > 65 ? 'rgba(234,179,8,0.2)' : 'rgba(34,197,94,0.2)',
}"
>
Workload: {{ agent.workload }}%
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -168,6 +190,29 @@ defineEmits<{
color: #6b7385; color: #6b7385;
font-weight: 500; font-weight: 500;
} }
.agent-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
opacity: 0.4;
cursor: pointer;
transition: opacity 0.2s;
flex-shrink: 0;
text-decoration: none;
vertical-align: middle;
}
.agent-link-btn:hover {
opacity: 1;
color: var(--agent-color);
}
.modal-close-btn { .modal-close-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -211,6 +256,9 @@ defineEmits<{
font-size: 12px; font-size: 12px;
color: #e8eaf0; color: #e8eaf0;
line-height: 1.4; line-height: 1.4;
display: flex;
align-items: center;
gap: 8px;
} }
/* Progress */ /* Progress */
@@ -241,6 +289,87 @@ defineEmits<{
transition: width 0.5s ease; transition: width 0.5s ease;
} }
/* Live Thinking */
.thinking-panel {
position: relative;
border: 1px solid rgba(139, 124, 246, 0.2);
border-radius: 12px;
padding: 14px;
background: rgba(12, 16, 22, 0.6);
overflow: hidden;
animation: panel-pulse 2.5s ease-in-out infinite;
}
@keyframes panel-pulse {
0%, 100% { border-color: rgba(139, 124, 246, 0.25); box-shadow: 0 0 12px rgba(139,124,246,0.08); }
50% { border-color: rgba(139, 124, 246, 0.5); box-shadow: 0 0 24px rgba(139,124,246,0.18), 0 0 40px rgba(139,124,246,0.06); }
}
.thinking-dots {
display: inline-flex;
gap: 6px;
flex-shrink: 0;
}
.thinking-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.thinking-dot.blue {
background: #3b82f6;
box-shadow: 0 0 8px #3b82f6;
animation: pulse-dot-blue 1.2s ease-in-out infinite;
}
.thinking-dot.violet {
background: #8b7cf6;
box-shadow: 0 0 8px #8b7cf6;
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
}
@keyframes pulse-dot-blue {
0%, 100% { opacity: 0.4; transform: scale(0.7); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes pulse-dot-violet {
0%, 100% { opacity: 0.3; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1.4); }
}
.thinking-stream {
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
z-index: 1;
}
.thinking-entry {
display: flex;
gap: 10px;
align-items: baseline;
animation: slide-in-right 0.3s ease-out both;
font-size: 10px;
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
.entry-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 42px;
}
.entry-text {
color: #9ea5b3;
line-height: 1.4;
}
.thinking-placeholder {
font-size: 10px;
color: #4a5160;
font-style: italic;
}
/* Working Feed */ /* Working Feed */
.work-feed { .work-feed {
display: flex; display: flex;
@@ -265,6 +394,13 @@ defineEmits<{
color: #7e8799; color: #7e8799;
line-height: 1.35; line-height: 1.35;
} }
.step-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 36px;
}
/* Footer */ /* Footer */
.modal-footer { .modal-footer {
+206 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick, watch } from 'vue' import { ref, nextTick, watch } from 'vue'
import { Bot, Send, LoaderCircle } from '@lucide/vue' import { Bot, Send, LoaderCircle, Maximize2, X } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData' import type { ChatMessage } from '../../composables/useDashboardData'
const props = defineProps<{ const props = defineProps<{
@@ -15,6 +15,8 @@ const emit = defineEmits<{
const inputText = ref('') const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null) const chatListRef = ref<HTMLElement | null>(null)
const chatModalListRef = ref<HTMLElement | null>(null)
const chatModalOpen = ref(false)
function sendMessage(): void { function sendMessage(): void {
if (!inputText.value.trim()) return if (!inputText.value.trim()) return
@@ -26,8 +28,9 @@ watch(
() => props.messages.length, () => props.messages.length,
async () => { async () => {
await nextTick() await nextTick()
if (chatListRef.value) { const el = chatModalOpen.value ? chatModalListRef.value : chatListRef.value
chatListRef.value.scrollTop = chatListRef.value.scrollHeight if (el) {
el.scrollTop = el.scrollHeight
} }
} }
) )
@@ -44,6 +47,9 @@ watch(
<LoaderCircle :size="10" class="spin" /> <LoaderCircle :size="10" class="spin" />
<span>Busy</span> <span>Busy</span>
</div> </div>
<button class="chat-expand-btn" @click="chatModalOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</button>
</div> </div>
<!-- Focus Bar --> <!-- Focus Bar -->
@@ -90,6 +96,68 @@ watch(
</button> </button>
</div> </div>
</div> </div>
<!-- Expanded Chat Modal -->
<Teleport to="body">
<div v-if="chatModalOpen" class="chat-modal-overlay" @click.self="chatModalOpen = false">
<div class="chat-modal">
<div class="chat-modal-header">
<div class="chat-modal-header-left">
<Bot :size="18" class="chat-header-icon" />
<h2>Iris Chat</h2>
</div>
<div v-if="irisBusy" class="busy-badge">
<LoaderCircle :size="10" class="spin" />
<span>Busy</span>
</div>
<button class="chat-modal-close" @click="chatModalOpen = false" title="Close">
<X :size="16" />
</button>
</div>
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<div ref="chatModalListRef" class="chat-modal-messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
>
<div v-if="msg.sender === 'iris'" class="msg-avatar">
<Bot :size="14" />
</div>
<div class="msg-bubble">
<p>{{ msg.text }}</p>
</div>
</div>
<div v-if="messages.length === 0" class="empty-state">
<p>No messages yet. Start a conversation with Iris.</p>
</div>
</div>
<div class="chat-modal-input-row">
<input
v-model="inputText"
type="text"
placeholder="Type a message..."
@keyup.enter="sendMessage"
/>
<button
class="send-btn"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="16" />
</button>
</div>
</div>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
@@ -152,6 +220,27 @@ watch(
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Expand Button */
.chat-expand-btn {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: #6b7385;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
margin-left: 6px;
}
.chat-expand-btn:hover {
background: rgba(167, 139, 250, 0.12);
border-color: rgba(167, 139, 250, 0.25);
color: #a78bfa;
}
/* Focus Bar */ /* Focus Bar */
.focus-bar { .focus-bar {
display: flex; display: flex;
@@ -293,4 +382,118 @@ watch(
.send-btn:not(:disabled):hover { .send-btn:not(:disabled):hover {
opacity: 0.85; opacity: 0.85;
} }
/* Chat Modal Overlay */
.chat-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.chat-modal {
display: flex;
flex-direction: column;
width: 90%;
max-width: 700px;
height: 70vh;
max-height: 80vh;
background: rgba(22, 27, 34, 0.92);
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: modal-in 0.2s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.chat-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-modal-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.chat-modal-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.chat-modal-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.chat-modal-close:hover {
background: rgba(255, 255, 255, 0.08);
color: #e8eaf0;
}
.chat-modal-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-modal-messages::-webkit-scrollbar {
width: 6px;
}
.chat-modal-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-modal-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.chat-modal-input-row {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-modal-input-row input {
flex: 1;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #e8eaf0;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
min-width: 0;
}
.chat-modal-input-row input:focus {
border-color: #a78bfa;
}
.chat-modal-input-row input::placeholder {
color: #6b7385;
}
</style> </style>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
const props = defineProps<{
entries: FeedEntry[]
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const selectedDayOffset = ref(0) // 0 = today, -1 = yesterday, etc.
function close() {
emit('update:modelValue', false)
}
function dayLabel(offset: number): string {
if (offset === 0) return 'Heute'
if (offset === -1) return 'Gestern'
if (offset === -2) return 'Vorgestern'
const d = new Date()
d.setDate(d.getDate() + offset)
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function navigateDay(dir: -1 | 1) {
const next = selectedDayOffset.value + dir
if (next >= -6 && next <= 0) {
selectedDayOffset.value = next
}
}
const filteredEntries = computed(() => {
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + selectedDayOffset.value)
const targetStr = targetDate.toISOString().slice(0, 10)
return props.entries.filter(e => e.timestamp.slice(0, 10) === targetStr)
})
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
<div class="feed-modal-card">
<div class="feed-modal-header">
<h2 class="feed-modal-title">Operations Log</h2>
<button class="feed-modal-close-btn" @click="close" aria-label="Close">
<X :size="16" />
</button>
</div>
<div class="feed-modal-nav">
<button
class="feed-nav-btn"
:disabled="selectedDayOffset <= -6"
@click="navigateDay(-1)"
aria-label="Previous day"
>
<ChevronLeft :size="14" />
</button>
<span class="feed-nav-label">{{ dayLabel(selectedDayOffset) }}</span>
<button
class="feed-nav-btn"
:disabled="selectedDayOffset >= 0"
@click="navigateDay(1)"
aria-label="Next day"
>
<ChevronRight :size="14" />
</button>
</div>
<div class="feed-modal-entries">
<div v-if="filteredEntries.length === 0" class="feed-modal-empty">
Keine Einträge für diesen Tag.
</div>
<div
v-for="(entry, idx) in filteredEntries"
:key="entry.timestamp + '-' + idx"
class="feed-modal-entry"
>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.feed-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
padding: 20px;
animation: feed-overlay-in 0.2s ease;
}
@keyframes feed-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.feed-modal-card {
background: #161b22;
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
animation: feed-card-in 0.25s ease;
}
@keyframes feed-card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.feed-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.feed-modal-close-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
color: #7e8799;
cursor: pointer;
transition: all 0.15s;
}
.feed-modal-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e8eaf0;
}
.feed-modal-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.feed-nav-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid rgba(139, 124, 246, 0.15);
background: rgba(139, 124, 246, 0.08);
border-radius: 8px;
color: #a78bfa;
cursor: pointer;
transition: all 0.15s;
}
.feed-nav-btn:hover:not(:disabled) {
background: rgba(139, 124, 246, 0.16);
border-color: rgba(139, 124, 246, 0.3);
}
.feed-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.feed-nav-label {
font-size: 12px;
font-weight: 600;
color: #d1d5db;
min-width: 100px;
text-align: center;
}
.feed-modal-entries {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 50vh;
padding-right: 4px;
}
.feed-modal-empty {
text-align: center;
padding: 24px 0;
font-size: 11px;
color: #6b7385;
}
.feed-modal-entry {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-modal-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
}
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
</style>
@@ -1,201 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, ChevronRight } from '@lucide/vue'
import type { MissionData } from '../../composables/useDashboardData'
const props = defineProps<{
mission: MissionData
}>()
const statusColor: Record<string, string> = {
healthy: '#22c55e',
attention: '#eab308',
blocked: '#ef4444',
paused: '#6b7280',
}
const statusLabel = computed(() => {
const map: Record<string, string> = {
healthy: 'Healthy',
attention: 'Warning',
blocked: 'Blocked',
paused: 'Paused',
}
return map[props.mission.status] ?? props.mission.status
})
</script>
<template>
<article class="mission-card" tabindex="0">
<div class="mission-head">
<h3>{{ mission.name }}</h3>
<span
class="mission-status"
:style="{ color: statusColor[mission.status] }"
>
{{ statusLabel }}
</span>
</div>
<div class="progress-track">
<div
class="progress-fill"
:style="{
width: `${mission.progress}%`,
background: `linear-gradient(90deg, ${statusColor[mission.status]}, color-mix(in srgb, ${statusColor[mission.status]} 65%, #fff))`,
}"
></div>
</div>
<div class="mission-body">
<div class="mission-detail">
<span class="detail-label">Current Task</span>
<span class="detail-value">{{ mission.currentTask }}</span>
</div>
<div class="mission-footer">
<div class="mission-meta">
<Clock :size="10" />
<span>{{ mission.lastActivity }}</span>
</div>
<div class="mission-tasks">
<span class="tasks-count">{{ mission.remainingTasks }}</span>
<span class="tasks-label">remaining</span>
</div>
</div>
</div>
<div class="mission-arrow">
<ChevronRight :size="14" />
</div>
</article>
</template>
<style scoped>
.mission-card {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.mission-card:hover {
border-color: rgba(139, 124, 246, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.mission-card:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
.mission-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mission-head h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mission-status {
font-size: 8px;
font-weight: 600;
text-transform: capitalize;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.progress-track {
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.mission-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mission-detail {
display: flex;
flex-direction: column;
gap: 3px;
}
.detail-label {
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.detail-value {
font-size: 10px;
color: #7e8799;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mission-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.mission-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #6b7385;
}
.mission-tasks {
display: flex;
align-items: center;
gap: 4px;
}
.tasks-count {
font-size: 12px;
font-weight: 700;
color: #a78bfa;
font-variant-numeric: tabular-nums;
}
.tasks-label {
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mission-arrow {
position: absolute;
right: 10px;
bottom: 10px;
color: #6b7385;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.mission-card:hover .mission-arrow {
opacity: 1;
transform: translateX(2px);
}
</style>
@@ -1,10 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { Activity } from '@lucide/vue' import { Activity } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData' import type { FeedEntry } from '../../composables/useDashboardData'
import FeedDetailModal from './FeedDetailModal.vue'
defineProps<{ const props = defineProps<{
entries: FeedEntry[] entries: FeedEntry[]
}>() }>()
// ── Compact feed (5 items) ──
const compactEntries = computed(() => props.entries.slice(0, 5))
// ── Feed Detail Modal ──
const showDetailModal = ref(false)
function openDetailModal() {
showDetailModal.value = true
}
</script> </script>
<template> <template>
@@ -17,7 +29,7 @@ defineProps<{
<div class="feed-list"> <div class="feed-list">
<TransitionGroup name="feed"> <TransitionGroup name="feed">
<div <div
v-for="(entry, idx) in entries.slice(0, 8)" v-for="(entry, idx) in compactEntries"
:key="entry.timestamp + '-' + idx" :key="entry.timestamp + '-' + idx"
class="feed-entry" class="feed-entry"
> >
@@ -33,7 +45,17 @@ defineProps<{
<div v-if="entries.length === 0" class="feed-empty"> <div v-if="entries.length === 0" class="feed-empty">
<span>No operations recorded yet.</span> <span>No operations recorded yet.</span>
</div> </div>
<button v-if="entries.length > 5" class="feed-more-btn" @click="openDetailModal">
Mehr anzeigen
</button>
</div> </div>
<FeedDetailModal
:entries="entries"
:model-value="showDetailModal"
@update:model-value="showDetailModal = $event"
/>
</div> </div>
</template> </template>
@@ -121,9 +143,8 @@ defineProps<{
} }
.feed-action { .feed-action {
color: #7e8799; color: #7e8799;
white-space: nowrap; white-space: normal;
overflow: hidden; word-break: break-word;
text-overflow: ellipsis;
} }
.feed-empty { .feed-empty {
@@ -133,6 +154,26 @@ defineProps<{
color: #6b7385; color: #6b7385;
} }
.feed-more-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
color: #a78bfa;
font-size: 9.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.feed-more-btn:hover {
background: rgba(139, 124, 246, 0.14);
border-color: rgba(139, 124, 246, 0.2);
}
/* TransitionGroup */ /* TransitionGroup */
.feed-enter-active { .feed-enter-active {
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -76,7 +76,7 @@ function onDragEnd(): void {
<div class="queue-header" @click="expanded = !expanded"> <div class="queue-header" @click="expanded = !expanded">
<div class="queue-header-left"> <div class="queue-header-left">
<ListTodo :size="14" class="queue-icon" /> <ListTodo :size="14" class="queue-icon" />
<h2>Queue</h2> <h2>Chat Queue</h2>
<span class="queue-count">{{ items.length }}</span> <span class="queue-count">{{ items.length }}</span>
</div> </div>
<button class="queue-toggle" aria-label="Toggle"> <button class="queue-toggle" aria-label="Toggle">
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Circle, ChevronRight } from '@lucide/vue'
import type { OpenTask } from '../../composables/useDashboardData'
defineProps<{
tasks: OpenTask[]
}>()
const emit = defineEmits<{
newTask: []
'go-board': []
}>()
const expandedId = ref<string | null>(null)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
</script>
<template>
<div class="task-card-panel">
<div class="task-header">
<h2 class="task-title">Offene Aufgaben</h2>
<button class="new-task-btn" @click="emit('newTask')">
<Plus :size="12" />
<span>New Task</span>
</button>
</div>
<div class="task-list">
<div v-if="tasks.length === 0" class="task-empty">
Keine offenen Aufgaben. Erstelle eine mit + New Task.
</div>
<TransitionGroup name="task">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ expanded: expandedId === task.id }"
@click="toggleExpand(task.id)"
>
<div class="task-main">
<Circle
:size="8"
class="task-source-dot"
:class="task.source === 'iris' ? 'dot-iris' : 'dot-bao'"
fill="currentColor"
/>
<div class="task-content">
<div class="task-title-row">
<span class="task-name">{{ task.title }}</span>
<span class="task-time">{{ task.createdAt }}</span>
</div>
<span
class="task-source-tag"
:class="task.source === 'iris' ? 'tag-iris' : 'tag-bao'"
>
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
</span>
</div>
</div>
<div v-if="expandedId === task.id" class="task-detail">
{{ task.detail }}
</div>
</div>
</TransitionGroup>
</div>
<button class="task-board-btn" @click="emit('go-board')">
<span>Zum Task Board</span>
<ChevronRight :size="14" />
</button>
</div>
</template>
<style scoped>
.task-card-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.task-card-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-title {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.new-task-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(139, 124, 246, 0.12);
border: 1px solid rgba(139, 124, 246, 0.2);
border-radius: 6px;
color: #a78bfa;
font-size: 9px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.new-task-btn:hover {
background: rgba(139, 124, 246, 0.2);
border-color: rgba(139, 124, 246, 0.35);
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.task-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(139, 124, 246, 0.08);
}
.task-item.expanded {
background: rgba(139, 124, 246, 0.04);
border-color: rgba(139, 124, 246, 0.1);
}
.task-main {
display: flex;
align-items: flex-start;
gap: 8px;
}
.task-source-dot {
margin-top: 4px;
flex-shrink: 0;
}
.dot-iris {
color: #a78bfa;
}
.dot-bao {
color: #3b82f6;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-name {
font-size: 10px;
font-weight: 500;
color: #d1d5db;
line-height: 1.35;
}
.task-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.task-source-tag {
display: inline-block;
font-size: 8px;
font-weight: 600;
padding: 1px 7px;
border-radius: 4px;
letter-spacing: 0.02em;
align-self: flex-start;
line-height: 1.4;
}
.tag-iris {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.tag-bao {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.task-detail {
padding: 6px 10px;
margin: 0 0 2px 16px;
font-size: 9.5px;
color: #7e8799;
line-height: 1.45;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
border-left: 2px solid rgba(139, 124, 246, 0.2);
}
.task-empty {
text-align: center;
padding: 16px 8px;
font-size: 10px;
color: #6b7385;
line-height: 1.5;
}
.task-board-btn {
width: 100%; margin-top: 12px; padding: 10px;
display: flex; align-items: center; justify-content: center; gap: 6px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 10px; color: #a78bfa;
font-size: 10px; font-weight: 600; cursor: pointer;
transition: all 0.2s;
}
.task-board-btn:hover {
background: rgba(139, 124, 246, 0.15);
border-color: rgba(139, 124, 246, 0.3);
}
/* TransitionGroup */
.task-enter-active {
transition: all 0.3s ease;
}
.task-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.task-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.task-leave-to {
opacity: 0;
transform: translateY(6px);
}
.task-move {
transition: transform 0.3s ease;
}
</style>
+125 -290
View File
@@ -1,22 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue' import { ref, computed, toRef } from 'vue'
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue' import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
interface AgentData { import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
hero?: boolean
task?: string
runtime?: string
}
const props = defineProps<{ const props = defineProps<{
agents: AgentData[] agents: AgentNodeData[]
heroId?: string heroId?: string
activeAgents?: string[] activeAgents?: string[]
}>() }>()
@@ -25,31 +14,27 @@ const emit = defineEmits<{
select: [id: string] select: [id: string]
}>() }>()
// ── Layout refs ── // ── Network ref ──
const networkRef = ref<HTMLDivElement | null>(null) const networkRef = ref<HTMLDivElement | null>(null)
interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
// ── Computed data ── // ── Computed data ──
const hero = computed(() => props.agents.find(a => a.id === props.heroId) ?? props.agents[0]) const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
const childAgents = computed(() => props.agents.filter(a => a.id !== props.heroId))
function isActive(id: string): boolean { function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false return props.activeAgents?.includes(id) ?? false
} }
// ── SVG composable ──
const {
svgWidth,
svgHeight,
childAgents,
connectionPaths,
storePathRef,
storePulseRef,
storePulseRef2,
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
// ── Icon resolver ── // ── Icon resolver ──
function resolveIcon(iconName: string) { function resolveIcon(iconName: string) {
switch (iconName) { switch (iconName) {
@@ -63,204 +48,15 @@ function resolveIcon(iconName: string) {
} }
} }
// ── Position measurement ── // ── Runtime formatter ──
function updatePositions() { function formatRuntime(seconds: number): string {
if (!networkRef.value) return const m = Math.floor(seconds / 60)
const rect = networkRef.value.getBoundingClientRect() const s = seconds % 60
svgWidth.value = rect.width return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
svgHeight.value = rect.height
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(el => {
const id = el.getAttribute('data-agent-id')
if (!id) return
const r = el.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
} }
// ── SVG path computation ── // ── Hero computed ──
interface ConnectionPath { const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
d: string
length: number
}
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const heroEntry = props.agents.find(a => a.id === props.heroId)
const heroId = heroEntry?.id ?? ''
const iris = heroId ? pos[heroId] : undefined
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.30 + t * 0.40)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 40
const cp2x = endX + (isLeftColumn ? 50 : -50)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
let animFrameId: number | null = null
let lastAnimTime = 0
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `10 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
}
}
function startPulseAnimation() {
const speeds: Record<string, number> = {}
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + (speeds[id] ?? len / 3000) * dt
const cycleLen = len + 10
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
// Paths changed — recalculate lengths and dasharrays
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
</script> </script>
<template> <template>
@@ -316,7 +112,7 @@ onUnmounted(() => {
opacity="0.5" opacity="0.5"
/> />
<!-- Pulse line (white dashed segment moving along) --> <!-- Pulse line 1 (white dashed segment moving along) -->
<path <path
v-if="connectionPaths[agent.id]" v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)" :ref="storePulseRef(agent.id)"
@@ -328,6 +124,19 @@ onUnmounted(() => {
stroke-linejoin="round" stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4" :opacity="isActive(agent.id) ? 1 : 0.4"
/> />
<!-- Pulse line 2 (offset by half cycle) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef2(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 0.8 : 0.3"
/>
</template> </template>
</svg> </svg>
@@ -356,19 +165,21 @@ onUnmounted(() => {
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span> <span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
</div> </div>
<p class="card-desc">{{ hero.description }}</p> <p class="card-desc">{{ hero.description }}</p>
<span v-if="hero.task" class="node-task"> <div v-if="hero.currentTask" class="task-row">
<span class="node-task-dot"></span> <span class="node-task">
{{ hero.task }} <span class="node-task-dot"></span>
</span> {{ hero.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
<span v-if="hero.model" class="node-model">{{ hero.model }}</span>
</div>
<div class="card-tags"> <div class="card-tags">
<span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span> <span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer-action"> <div class="card-arrow">
<span>ROLE CARD</span> <span class="arrow-icon">&rarr;</span>
<span class="arrow">&rarr;</span>
<span v-if="hero.runtime" class="node-runtime">{{ hero.runtime }}</span>
</div> </div>
</article> </article>
</div> </div>
@@ -402,19 +213,21 @@ onUnmounted(() => {
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span> <span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
</div> </div>
<p class="card-desc">{{ agent.description }}</p> <p class="card-desc">{{ agent.description }}</p>
<span v-if="agent.task" class="node-task"> <div v-if="agent.currentTask" class="task-row">
<span class="node-task-dot"></span> <span class="node-task">
{{ agent.task }} <span class="node-task-dot"></span>
</span> {{ agent.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
<span v-if="agent.model" class="node-model">{{ agent.model }}</span>
</div>
<div class="card-tags"> <div class="card-tags">
<span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span> <span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="card-footer-action"> <div class="card-arrow">
<span>ROLE CARD</span> <span class="arrow-icon">&rarr;</span>
<span class="arrow">&rarr;</span>
<span v-if="agent.runtime" class="node-runtime">{{ agent.runtime }}</span>
</div> </div>
</article> </article>
</div> </div>
@@ -445,7 +258,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 32px; gap: 64px;
} }
.hero-slot { .hero-slot {
@@ -467,26 +280,33 @@ onUnmounted(() => {
transition: border-color 0.3s, box-shadow 0.3s; transition: border-color 0.3s, box-shadow 0.3s;
} }
/* ── Agent Card (inlined from old AgentCard.vue) ── */ /* ── Agent Card ── */
.agent-card { .agent-card {
background: var(--panel, #11141b); background: rgba(18, 22, 30, 0.45);
border: 1px solid var(--line, #1f2330); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px; border-radius: 12px;
padding: 18px; padding: 18px;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.agent-card:hover { .agent-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: var(--card-color, #8b7cf6); border-color: var(--card-color, #8b7cf6);
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent); box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
} }
.hero-card { .hero-card {
border-color: rgba(139, 124, 246, 0.2); background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06); box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
} }
.hero-card:hover { .hero-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: #8b7cf6; border-color: #8b7cf6;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12); box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
} }
@@ -535,6 +355,45 @@ onUnmounted(() => {
line-height: 1.5; line-height: 1.5;
margin: 0 0 8px; margin: 0 0 8px;
} }
/* ── Task + Runtime Row ── */
.task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.node-task {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #9ea5b3;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.node-model {
font-size: 8.5px;
color: #6b7385;
font-weight: 500;
flex-shrink: 0;
margin-left: 6px;
}
/* ── Tags ── */
.card-tags { .card-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -548,49 +407,25 @@ onUnmounted(() => {
border-radius: 5px; border-radius: 5px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.card-footer-action {
display: flex; /* ── Hover Arrow ── */
align-items: center; .card-arrow {
justify-content: flex-end; position: absolute;
gap: 6px; right: 12px;
margin-top: 12px; bottom: 12px;
padding-top: 10px;
border-top: 1px solid var(--line, #1f2330);
font-size: 9px;
font-weight: 600;
color: #6b7385; color: #6b7385;
text-transform: uppercase; opacity: 0;
letter-spacing: 0.06em; transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
} }
.card-footer-action .arrow { .agent-card:hover .card-arrow {
font-size: 13px; opacity: 1;
transform: translateX(0);
}
.arrow-icon {
font-size: 14px;
line-height: 1; line-height: 1;
}
.agent-card:hover .card-footer-action {
color: var(--card-color, #8b7cf6);
}
/* ── Node Task ── */
.node-task {
display: block; display: block;
font-size: 10px;
color: #9ea5b3;
margin-bottom: 8px;
line-height: 1.4;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
/* ── Node Runtime ── */
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
margin-left: auto;
} }
@media (max-width: 720px) { @media (max-width: 720px) {
+81 -75
View File
@@ -5,32 +5,34 @@ export interface AgentNodeData {
name: string name: string
role: string role: string
description: string description: string
tags: string[]
color: string color: string
icon: string icon: string
model?: string
hero?: boolean
currentTask: string currentTask: string
goal: string goal: string
progress: number progress: number
workload: number // 0-100 workload: number // 0-100
active: boolean active: boolean
runtimeSeconds: number runtimeSeconds: number
workingFeed: string[] workingFeed: Array<{ time: string; text: string }>
thinkingStream?: Array<{ time: string; text: string }>
} }
export interface MissionData { export interface OpenTask {
id: string id: string
name: string title: string
progress: number detail: string
currentTask: string source: 'bao' | 'iris'
lastActivity: string createdAt: string
remainingTasks: number
status: 'healthy' | 'attention' | 'blocked' | 'paused'
} }
export interface FeedEntry { export interface FeedEntry {
time: string time: string
agent: string agent: string
action: string action: string
timestamp: number timestamp: string
} }
export interface ChatMessage { export interface ChatMessage {
@@ -104,19 +106,27 @@ export function useDashboardData() {
name: 'Iris', name: 'Iris',
role: 'Chief of Staff', role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.', description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6', color: '#8b7cf6',
icon: 'bot', icon: 'bot',
hero: true,
currentTask: 'Orchestrating Nexus Dashboard redesign', currentTask: 'Orchestrating Nexus Dashboard redesign',
goal: 'Complete Mission Control v3', goal: 'Complete Mission Control v3',
progress: 85, progress: 85,
workload: 55, workload: 55,
active: true, active: true,
runtimeSeconds: 28800, runtimeSeconds: 28800,
model: 'GPT-5.4',
workingFeed: [ workingFeed: [
'Analyzed user feedback on Dashboard', { time: '22:38', text: 'Analyzed user feedback on Dashboard' },
'Delegated card redesign to Developer', { time: '22:36', text: 'Delegated card redesign to Developer' },
'Verifying full-width layout deployment', { time: '22:34', text: 'Verifying full-width layout deployment' },
'Reviewing AgentModal integration', { time: '22:32', text: 'Reviewing AgentModal integration' },
],
thinkingStream: [
{ time: '22:24', text: 'Analysing constraint: full-width layout' },
{ time: '22:25', text: 'Removing max-width from global CSS' },
{ time: '22:26', text: 'Verifying Dashboard grid reflow' },
], ],
}, },
{ {
@@ -124,6 +134,7 @@ export function useDashboardData() {
name: 'Developer', name: 'Developer',
role: 'Backend & Frontend', role: 'Backend & Frontend',
description: 'Implements features across the stack with TypeScript, C#, and Vue.', description: 'Implements features across the stack with TypeScript, C#, and Vue.',
tags: ['Coding', 'Development', 'Builds'],
color: '#3b82f6', color: '#3b82f6',
icon: 'code', icon: 'code',
currentTask: 'Building Dungeon System API endpoints', currentTask: 'Building Dungeon System API endpoints',
@@ -133,17 +144,24 @@ export function useDashboardData() {
active: true, active: true,
runtimeSeconds: 3600, runtimeSeconds: 3600,
workingFeed: [ workingFeed: [
'Created DungeonController', { time: '22:30', text: 'Created DungeonController' },
'Defined dungeon schema', { time: '22:28', text: 'Defined dungeon schema' },
'Implementing room generation algorithm', { time: '22:26', text: 'Implementing room generation algorithm' },
'Writing unit tests for RoomFactory', { time: '22:24', text: 'Writing unit tests for RoomFactory' },
], ],
thinkingStream: [
{ time: '22:22', text: 'Parsing dungeon spec from Iris' },
{ time: '22:23', text: 'Designing RoomFactory interface' },
{ time: '22:24', text: 'Implementing corridor connection logic' },
],
model: 'DeepSeek V4 Flash',
}, },
{ {
id: 'devops', id: 'devops',
name: 'DevOps', name: 'DevOps',
role: 'Infrastructure & CI/CD', role: 'Infrastructure & CI/CD',
description: 'Manages Docker, deployment pipelines, and system reliability.', description: 'Manages Docker, deployment pipelines, and system reliability.',
tags: ['Deployment', 'Docker', 'CI/CD'],
color: '#eab308', color: '#eab308',
icon: 'server', icon: 'server',
currentTask: 'Optimizing Docker Compose caching', currentTask: 'Optimizing Docker Compose caching',
@@ -153,17 +171,24 @@ export function useDashboardData() {
active: false, active: false,
runtimeSeconds: 1800, runtimeSeconds: 1800,
workingFeed: [ workingFeed: [
'Analyzed Docker layer cache', { time: '22:20', text: 'Analyzed Docker layer cache' },
'Optimized COPY order in Dockerfile', { time: '22:18', text: 'Optimized COPY order in Dockerfile' },
'Added .dockerignore for node_modules', { time: '22:16', text: 'Added .dockerignore for node_modules' },
'Testing incremental builds', { time: '22:14', text: 'Testing incremental builds' },
], ],
thinkingStream: [
{ time: '22:20', text: 'Checking build cache hit rates' },
{ time: '22:21', text: 'Benchmarking multi-stage vs single-stage' },
{ time: '22:22', text: 'Calculating potential speedup from caching' },
],
model: 'DeepSeek V4 Pro',
}, },
{ {
id: 'researcher', id: 'researcher',
name: 'Researcher', name: 'Researcher',
role: 'Analysis & Documentation', role: 'Analysis & Documentation',
description: 'Researches APIs, patterns, and best practices. Maintains docs.', description: 'Researches APIs, patterns, and best practices. Maintains docs.',
tags: ['Research', 'Analysis', 'Docs'],
color: '#22c55e', color: '#22c55e',
icon: 'search', icon: 'search',
currentTask: 'Analyzing WebSocket alternatives', currentTask: 'Analyzing WebSocket alternatives',
@@ -173,16 +198,23 @@ export function useDashboardData() {
active: true, active: true,
runtimeSeconds: 2700, runtimeSeconds: 2700,
workingFeed: [ workingFeed: [
'Evaluated WebSocket vs SSE vs WebRTC', { time: '22:18', text: 'Evaluated WebSocket vs SSE vs WebRTC' },
'Documented SignalR limitations', { time: '22:17', text: 'Documented SignalR limitations' },
'Prototyping WebSocket fallback', { time: '22:16', text: 'Prototyping WebSocket fallback' },
], ],
thinkingStream: [
{ time: '22:18', text: 'Cross-referencing WebSocket latency benchmarks' },
{ time: '22:19', text: 'Checking SSE browser support matrix' },
{ time: '22:20', text: 'Drafting recommendation summary' },
],
model: 'DeepSeek V4 Pro',
}, },
{ {
id: 'reviewer', id: 'reviewer',
name: 'Reviewer', name: 'Reviewer',
role: 'Code Quality & Testing', role: 'Code Quality & Testing',
description: 'Reviews pull requests, enforces standards, runs test suites.', description: 'Reviews pull requests, enforces standards, runs test suites.',
tags: ['Code Review', 'Testing', 'Quality'],
color: '#a855f7', color: '#a855f7',
icon: 'shield', icon: 'shield',
currentTask: 'Reviewing Dungeon System PR', currentTask: 'Reviewing Dungeon System PR',
@@ -192,64 +224,38 @@ export function useDashboardData() {
active: false, active: false,
runtimeSeconds: 900, runtimeSeconds: 900,
workingFeed: [ workingFeed: [
'Reviewed DungeonController.cs', { time: '22:15', text: 'Reviewed DungeonController.cs' },
'Found 3 minor style issues', { time: '22:14', text: 'Found 3 minor style issues' },
'Approved RoomValidator', { time: '22:13', text: 'Approved RoomValidator' },
'Running integration tests', { time: '22:12', text: 'Running integration tests' },
], ],
thinkingStream: [
{ time: '22:15', text: 'Analyzing DungeonController PR diff' },
{ time: '22:16', text: 'Checking RoomValidator edge cases' },
{ time: '22:17', text: 'Verifying integration test coverage' },
],
model: 'DeepSeek V4 Pro',
}, },
]) ])
// Missions // Open Tasks
const missions = ref<MissionData[]>([ const openTasks = ref<OpenTask[]>([
{ { id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' },
id: 'dungeon-system', { id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' },
name: 'Dungeon System', { id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' },
progress: 62,
currentTask: 'Implement room generation',
lastActivity: '3 min ago',
remainingTasks: 8,
status: 'healthy',
},
{
id: 'dashboard-redesign',
name: 'Dashboard Redesign',
progress: 45,
currentTask: 'AI Team Network layout',
lastActivity: 'Just now',
remainingTasks: 6,
status: 'healthy',
},
{
id: 'infra-optimization',
name: 'Infra Optimization',
progress: 30,
currentTask: 'Optimize build caching',
lastActivity: '12 min ago',
remainingTasks: 4,
status: 'attention',
},
{
id: 'auth-system',
name: 'Auth System',
progress: 88,
currentTask: 'Finalize refresh token flow',
lastActivity: '45 min ago',
remainingTasks: 2,
status: 'healthy',
},
]) ])
// Feed // Feed
const ts = (offset: number) => new Date(now + offset).toISOString()
const feedEntries = ref<FeedEntry[]>([ const feedEntries = ref<FeedEntry[]>([
{ time: '20:42', agent: 'Developer', action: 'Created DungeonController endpoints', timestamp: now - 60000 }, { time: '22:50', agent: 'Developer', action: 'Created DungeonController endpoints', timestamp: ts(-120000) },
{ time: '20:38', agent: 'DevOps', action: 'Optimized Docker COPY order', timestamp: now - 300000 }, { time: '22:46', agent: 'DevOps', action: 'Optimized Docker COPY order for layer caching', timestamp: ts(-360000) },
{ time: '20:35', agent: 'Iris', action: 'Delegated room generation to Developer', timestamp: now - 540000 }, { time: '22:42', agent: 'Iris', action: 'Delegated room generation to Developer with spec', timestamp: ts(-600000) },
{ time: '20:28', agent: 'Researcher', action: 'Documented WebSocket vs SSE analysis', timestamp: now - 780000 }, { time: '22:35', agent: 'Researcher', action: 'Documented WebSocket vs SSE analysis results', timestamp: ts(-960000) },
{ time: '20:22', agent: 'Reviewer', action: 'Approved RoomValidator PR', timestamp: now - 900000 }, { time: '22:28', agent: 'Reviewer', action: 'Approved RoomValidator PR with minor fixes', timestamp: ts(-1200000) },
{ time: '20:15', agent: 'DevOps', action: 'Added .dockerignore for node_modules', timestamp: now - 1200000 }, { time: '22:18', agent: 'DevOps', action: 'Added .dockerignore for node_modules and build artifacts', timestamp: ts(-1500000) },
{ time: '20:08', agent: 'Iris', action: 'Broke down Dungeon System tasks', timestamp: now - 1500000 }, { time: '22:08', agent: 'Iris', action: 'Broke down Dungeon System tasks into sub-tasks', timestamp: ts(-1800000) },
{ time: '19:55', agent: 'Developer', action: 'Defined dungeon schema models', timestamp: now - 1800000 }, { time: '21:55', agent: 'Developer', action: 'Defined dungeon schema models with validation', timestamp: ts(-2400000) },
]) ])
// Chat // Chat
@@ -298,7 +304,7 @@ export function useDashboardData() {
return { return {
agents, agents,
missions, openTasks,
feedEntries, feedEntries,
chatMessages, chatMessages,
irisBusy, irisBusy,
@@ -0,0 +1,266 @@
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
import type { AgentNodeData } from './useDashboardData'
export interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
export interface ConnectionPath {
d: string
length: number
}
export function useTeamNetworkSvg(
networkRef: Ref<HTMLElement | null>,
agents: Ref<AgentNodeData[]>,
heroId: Ref<string>,
isActive: (id: string) => boolean,
) {
// ── Layout ──
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
function updatePositions() {
const el = networkRef.value
if (!el) return
const rect = el.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = el.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(card => {
const id = card.getAttribute('data-agent-id')
if (!id) return
const r = card.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
}
// ── Connection paths ──
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const iris = pos[heroId.value]
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.38 + t * 0.24)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 70
const cp2x = endX + (isLeftColumn ? 35 : -35)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Path refs (template ref functions) ──
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
const pulseOffsets2 = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function storePulseRef2(id: string) {
return (el: SVGPathElement | null) => {
pulseElements2.value[id] = el
}
}
// ── Pulse animation ──
let animFrameId: number | null = null
let lastAnimTime = 0
const speeds: Record<string, number> = {}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
const pulseEl2 = pulseElements2.value[id]
if (pulseEl2 && p && p.length > 0) {
if (pulseOffsets2.value[id] === undefined) {
pulseOffsets2.value[id] = 0
}
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
}
}
}
function startPulseAnimation() {
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.value[id] = 0
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const pulseEl2 = pulseElements2.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const speed = speeds[id] ?? len / 3000
const cycleLen = len + 40
// Pulse 1
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + speed * dt
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
// Pulse 2 (offset by half cycle)
if (pulseEl2) {
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
pulseOffsets2.value[id] = offset2
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
}
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
return {
cardPositions,
svgWidth,
svgHeight,
childAgents,
connectionPaths,
pathElements,
pulseElements,
pulseElements2,
pulseOffsets,
pulseOffsets2,
storePathRef,
storePulseRef,
storePulseRef2,
updatePositions,
refreshPathLengths,
}
}
+5 -9
View File
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import MissionCard from '../components/dashboard/MissionCard.vue' import TaskCard from '../components/dashboard/TaskCard.vue'
import OperationsFeed from '../components/dashboard/OperationsFeed.vue' import OperationsFeed from '../components/dashboard/OperationsFeed.vue'
import TeamNetwork from '../components/dashboard/TeamNetwork.vue' import TeamNetwork from '../components/dashboard/TeamNetwork.vue'
import ChatPanel from '../components/dashboard/ChatPanel.vue' import ChatPanel from '../components/dashboard/ChatPanel.vue'
import QueuePanel from '../components/dashboard/QueuePanel.vue' import QueuePanel from '../components/dashboard/QueuePanel.vue'
import AgentModal from '../components/dashboard/AgentModal.vue' import AgentModal from '../components/dashboard/AgentModal.vue'
import { useDashboardData } from '../composables/useDashboardData' import { useDashboardData } from '../composables/useDashboardData'
import type { AgentNodeData } from '../../composables/useDashboardData' import type { AgentNodeData } from '../composables/useDashboardData'
const { const {
agents, missions, feedEntries, chatMessages, agents, openTasks, feedEntries, chatMessages,
irisBusy, irisFocus, irisRuntime, queue, irisBusy, irisFocus, queue,
getAgentRuntime, startRuntime, stopRuntime, getAgentRuntime, startRuntime, stopRuntime,
sendChat, removeQueueItem, moveQueueItem, changeQueuePriority, sendChat, removeQueueItem, moveQueueItem, changeQueuePriority,
} = useDashboardData() } = useDashboardData()
@@ -48,8 +48,7 @@ function onQueueExecuteNow(id: string): void {
<div class="dashboard"> <div class="dashboard">
<div class="col-left"> <div class="col-left">
<section class="missions-section"> <section class="missions-section">
<h2 class="column-title">Active Missions</h2> <TaskCard :tasks="openTasks" @new-task="console.log('New task requested')" @go-board="console.log('Go to Task Board')" />
<MissionCard v-for="m in missions" :key="m.id" :mission="m" />
</section> </section>
<OperationsFeed :entries="feedEntries" /> <OperationsFeed :entries="feedEntries" />
</div> </div>
@@ -69,9 +68,6 @@ function onQueueExecuteNow(id: string): void {
<TeamNetwork <TeamNetwork
hero-id="iris" hero-id="iris"
:agents="agents" :agents="agents"
:iris-runtime="irisRuntime"
:get-agent-runtime="getAgentRuntime"
:iris-focus="irisFocus"
@select="onAgentSelect" @select="onAgentSelect"
/> />