Compare commits

..

12 Commits

Author SHA1 Message Date
devops 6d0dab4889 chore: bump version to v0.2.35 [skip ci] 2026-06-09 22:34:38 +00:00
developer dd509a75be fix: Agents hartcodiert mit File-Activity-Detection
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
- Tools/invoke Visibility scoped auf agent:main → keine Iris-Sessions sichtbar
- Hardcoded 6 Agenten mit korrekten Models
- Activity: checkt /mnt/workspace-{id}/memory Dateizeitstempel
2026-06-10 00:33:49 +02:00
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
7 changed files with 763 additions and 5 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.29 0.2.35
+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>();
+293
View File
@@ -0,0 +1,293 @@
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()
{
// Hardcoded agent list (known from OpenClaw config).
// Tools/invoke visibility is restricted to agent:main scope,
// so we can't enumerate Iris sessions programmatically.
var agentDefs = new[]
{
new { Id = "iris", Name = "Iris", Model = "GPT-5.4" },
new { Id = "programmer", Name = "Programmer", Model = "DeepSeek V4 Flash" },
new { Id = "reviewer", Name = "Reviewer", Model = "DeepSeek V4 Pro" },
new { Id = "architekt", Name = "DevOps", Model = "DeepSeek V4 Pro" },
new { Id = "executor", Name = "Executor", Model = "DeepSeek V4 Flash" },
new { Id = "researcher", Name = "Researcher", Model = "DeepSeek V4 Pro" },
};
var agents = new List<DashboardAgentInfo>();
foreach (var def in agentDefs)
{
var isActive = false;
string? currentTask = null;
// Check workspace activity via file timestamps
var memDir = $"/mnt/workspace-{def.Id}/memory";
try
{
if (Directory.Exists(memDir))
{
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (latestFile is not null)
{
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
isActive = age.TotalMinutes < 15;
if (isActive)
currentTask = $"Modified: {latestFile.Name}";
}
}
}
catch { /* workspace not mounted or inaccessible */ }
agents.Add(new DashboardAgentInfo(
Id: def.Id,
Name: def.Name,
Role: DeriveRole(def.Id),
Model: def.Model,
IsActive: isActive,
CurrentTask: 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"
};
}
+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>
@@ -44,7 +44,7 @@ const filteredEntries = computed(() => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="feed-modal-overlay" @click.self="close"> <div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
<div class="feed-modal-card"> <div class="feed-modal-card">
<div class="feed-modal-header"> <div class="feed-modal-header">
<h2 class="feed-modal-title">Operations Log</h2> <h2 class="feed-modal-title">Operations Log</h2>