Compare commits

..

7 Commits

Author SHA1 Message Date
devops b093b0c4b5 restore: reapply 50d95fa UI foundation clobbered by sync in 2ee1fe9
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
My rsync --delete based deploy sync overwrote the working tree with a stale
local copy and accidentally reverted commit 50d95fa (V2 Design-Fundament,
task ea7c2063) — new ui/ components deleted, token/color work in
dashboard/v2 and nexus-tokens.css rolled back. This restores all 17 files
exactly from 50d95fa; no overlap with the modal rework files (TaskBoardView,
tasks store, BoardCard), which stay as committed in 2ee1fe9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:13:04 +02:00
devops 2ee1fe973f feat(board): rework quick-peek modal (Linear-style) + board toolbar
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Modal redesign — compact summary instead of two long cards:
- Topbar: breadcrumb + state pill + copyable task-id/link chip; review
  actions (approve / request changes) live in the modal header too
- Main column: title, meta chips (ball, created, last update), compact
  description, then square summary tiles (done/total with progress,
  active, open, blocked) + per-agent pills — details stay in Vollansicht
- Right sidebar: properties as slim Linear-style rows (status, priority,
  assignee, due, source) with hover-reveal controls; activity moved next
  to properties as a compact timeline with a new comment composer
  (POST tasks/{id}/activity via new store action postTaskActivity)
- Unified button system (32px: primary/ghost/approve/changes/icon)
- flattenBoard includes nested children again so quick-peek works for
  child rows inside master cards

Board (issue-tracker basics):
- Toolbar with search (title/detail/id/child titles) and ball filters:
  Alle / Du bist dran / Bei Iris / Haengt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:51 +02:00
developer 50d95fa7a9 feat(ui): V2 Design-Fundament — UI-Komponenten konsolidieren und tokenisieren
## Neue wiederverwendbare Komponenten in components/ui/:
- StatusDot.vue — Animierte Status-Punkte (work/think/idle/block/queue)
- PageHeading.vue — Standardisierter Gradient-Seiten-Header
- EmptyState.vue — Shared Empty-State mit Icon/Title/Description/Action
- ActivityTimeline.vue — Wiederverwendbarer Aktivitäts-Feed
- SectionHeader.vue — Spalten-/Sektions-Header (dot + label + count)

## Token-Konformität hergestellt:
- Badge.vue: +12 nexus-token-basierte Varianten (work/think/blocked/queue/done/review/iris/bao/agent/priority*)
- Card.vue: glass-panel/raised/subtle Varianten via nexus-tokens.css
- button/index.ts: +gradient, +icon, +ghostSubtle, +danger Varianten
- nexus-tokens.css: Buttons + --clr-* Semantic Colors (iris/bao/agent/review/stale/other)

## Hartkodierte Farben eliminiert (0 verbleibend):
- dashboard/v2/*: alle #hex → var(--)* ersetzt
- components/ui/*: alle #hex → var(--)* ersetzt

Task: ea7c2063
Build:  grün
2026-07-11 11:09:31 +02:00
devops aef76d5f45 feat(board): master-task board, non-destructive stall watchdog, review flow
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
  nested inside their parent card instead of as separate column cards, so a
  big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
  children directly instead of scraping the flat board

Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
  tasks with no activity past the threshold as stalled (activity event +
  Iris notification) WITHOUT resetting the column — no work is discarded.
  Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
  interval 10m); hard reset kept only on the explicit manual endpoint

Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
  ExpectedFrom=iris, notifies Iris)

Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
  children, expand to show children grouped by agent with per-child state +
  stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions

Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:26:50 +02:00
devops f564ecfbc7 feat: unified rail shell, robust live sync, task board performance
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Shell:
- One shared NexusLayout for ALL routes (dashboard + pages): compact 68px
  icon rail with hover-expand overlay, replaces both old sidebars + topbars
- Single flat nav source (railNav) — same menu everywhere incl. Settings
- Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup,
  NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView

Live updates:
- Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and
  ChannelReader.ReadAsync were re-invoked while pending — every published
  update threw InvalidOperationException and killed ALL live streams
- liveSync store: treat graceful stream close as disconnect (was stuck
  connected=true with polling stopped -> page frozen until manual reload),
  fast first retry, heartbeat watchdog (65s), reconnect on online/visibility
- One app-wide SSE connection owned by the layout instead of per-view
  connect/disconnect churn; removed duplicate live-sync.ts store

Performance:
- Board endpoint: drop nested childTasks duplication (counts stay) — payload
  104KB -> 65KB; SSE snapshots shrink equally
- nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire);
  text/event-stream excluded to keep SSE unbuffered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:25:26 +02:00
devops 86ceb2bcce style: unify all views on Mission Control V2 design tokens
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
- Map legacy v1 CSS variables (--nx-*, --panel, --text-*, --surface*) to V2 tokens in nexus-tokens.css
- Restyle global shell (main.css), AppSidebar, AppHeader to match V2 Sidebar/Topbar (glass, gradients, Space Grotesk)
- Add GalaxyBackground to the v1 shell in App.vue
- Replace hardcoded v1 hex colors with V2 tokens in all deviating views (Agents, Calendar, Docs, Incidents, Memory, Notifications, ProjectDetail, Security, Team, TaskDetail, Settings)
- Keep JS status colors as hex where alpha suffixes are concatenated (AgentsIndex, Team, Notifications)
- Add Settings nav item (System group) + gear icon to V2 sidebar so it shows on the dashboard
- No component or layout structure changes; LoginView and TaskBoardView untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:05:52 +02:00
devops 706ff82ccd fix: harden Nexus runtime health and rollback 2026-07-10 00:37:24 +02:00
67 changed files with 2966 additions and 3118 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=***
# ── OpenClaw Integration ────────────────────────────────
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
OPENCLAW_BASE_URL=http://host.docker.internal:18789
# Internal Docker-DNS URL of the OpenClaw gateway
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=***
OPENCLAW_GATEWAY_PASSWORD=***
+10 -4
View File
@@ -60,7 +60,7 @@ JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=${VERSION}
@@ -140,9 +140,15 @@ echo "Checking live health"
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
echo "Health check passed"
break
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
case "$health_body" in
'{"status":"Healthy"'*)
echo "Health check passed"
break
;;
esac
if [ -n "$health_body" ]; then
echo "Health endpoint is reachable but not healthy: $health_body" >&2
fi
if [ "$retry" -eq 6 ]; then
echo "Health check failed" >&2
+50 -23
View File
@@ -112,9 +112,11 @@ jobs:
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
NEXUS_GIT_SHA=$(git rev-parse HEAD)
EOF
chmod 600 "${ENV_TMPFILE}"
@@ -127,18 +129,38 @@ jobs:
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
git archive --format=tar HEAD | docker run --rm -i \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
sh -c '
set -eu
dest_owner="$(stat -c "%u:%g" /dest)"
mkdir -p /src-snapshot
tar -xf - -C /src-snapshot
is_protected_path() {
case "$1" in
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then rm -rf "$path"; fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
done
chown -R "$dest_owner" /dest
'
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
@@ -151,16 +173,18 @@ jobs:
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
sh -c '
set -eu
umask 077
cat > /tmp/nexus-rollback-env
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
' < "${ENV_TMPFILE}"
echo "✅ Rollback redeploy completed"
@@ -186,11 +210,14 @@ jobs:
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
case "$HEALTH_BODY" in
'{"status":"Healthy"'*)
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
;;
esac
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
+94 -15
View File
@@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests
var recoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
liveUpdateService,
new FakeNotificationService());
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
@@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests
}
[Fact]
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stalled agent task",
State = "In progress",
Source = "iris",
UpdatedAt = stalledTimestamp,
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(1, flagged);
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(0, flaggedAgain);
}
[Fact]
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection();
@@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 4,
IntervalMinutes = 30
StalledMinutes = 45,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
Assert.Equal(7, resetCount);
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
Assert.Equal(7, flaggedCount);
}
[Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 2,
IntervalMinutes = 30
StalledMinutes = 40,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
@@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests
await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
}
[Fact]
@@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{
public int CallCount { get; private set; }
public int FlagCallCount { get; private set; }
public int ResetCallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; }
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
FlagCallCount++;
LastThreshold = stalledThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
CallCount++;
ResetCallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class FakeNotificationService : INotificationService
{
public List<Notification> Created { get; } = [];
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
Created.Add(notification);
return Task.FromResult(notification);
}
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;
+2 -1
View File
@@ -440,7 +440,8 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
liveUpdateService,
notificationService);
var taskService = new TaskService(
taskRepository,
+94 -38
View File
@@ -236,38 +236,60 @@ public class DashboardController(
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested)
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
// mit einer InvalidOperationException.
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
try
{
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
while (!ct.IsCancellationRequested)
{
var envelope = await readTask;
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
continue;
envelope = envelope with { Payload = snapshot };
}
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (envelope.Type == "tasks.board.snapshot")
if (completed == readTask)
{
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
}
var envelope = await readTask;
readTask = subscription.Reader.ReadAsync(ct).AsTask();
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
{
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
continue;
envelope = envelope with { Payload = snapshot };
}
if (envelope.Type == "tasks.board.snapshot")
{
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
}
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else
{
var ticked = await heartbeatTask;
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
if (!ticked) break;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
}
}
}
catch (OperationCanceledException)
{
// Client hat die Verbindung beendet — normal.
}
catch (System.Threading.Channels.ChannelClosedException)
{
// Subscription serverseitig geschlossen — Stream regulär beenden.
}
}
[HttpPatch("tasks/{id:guid}/move")]
@@ -300,6 +322,52 @@ public class DashboardController(
};
}
// ── Review-Aktionen (Bao/Iris) ──
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/approve")]
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
{
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
var result = await taskService.ApproveReviewAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/request-changes")]
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
@@ -331,19 +399,7 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var board = await taskService.GetBoardAsync(ct);
var children = board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done)
.Where(task => task.ParentTaskId == id)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
return Ok(children);
}
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
[HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
+7 -1
View File
@@ -99,7 +99,8 @@ public sealed record DashboardTaskDto(
List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0,
int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false
bool HasVisibleDelegation = false,
int DoneChildTaskCount = 0
);
public sealed record CreateDashboardTaskRequest(
@@ -183,6 +184,11 @@ public sealed record PostActivityRequest(
string? Type = null
);
public sealed record RequestChangesRequest(
string Comment,
string? TargetState = null
);
// ── Agent Workflow DTOs ──
/// <summary>
@@ -2,5 +2,9 @@ namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService
{
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+3
View File
@@ -33,9 +33,12 @@ public interface ITaskService
// Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
{
try
{
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
if (resetCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
if (flaggedCount > 0)
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -37,10 +37,10 @@ public sealed class StaleTaskRecoveryBackgroundService(
}
}
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
{
await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
}
}
+9 -1
View File
@@ -4,8 +4,16 @@ public sealed class StaleTaskRecoveryOptions
{
public const string SectionName = "TaskRecovery";
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
public int StalledMinutes { get; set; } = 40;
/// <summary>Prüfintervall des Watchdogs.</summary>
public int IntervalMinutes { get; set; } = 10;
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
public int StaleHours { get; set; } = 2;
public int IntervalMinutes { get; set; } = 30;
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
+79 -97
View File
@@ -7,8 +7,81 @@ namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
ILiveUpdateService liveUpdateService,
INotificationService notificationService) : IStaleTaskRecoveryService
{
private const string StalledActivityType = "stalled";
/// <summary>
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
/// </summary>
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var threshold = now - stalledThreshold;
var allTasks = await taskRepository.GetAllAsync(ct);
var inProgress = allTasks
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
.ToList();
if (inProgress.Count == 0)
return 0;
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
var activityByTask = activities
.Where(a => a.TaskId.HasValue)
.GroupBy(a => a.TaskId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
var flaggedCount = 0;
foreach (var task in inProgress)
{
activityByTask.TryGetValue(task.Id, out var taskActivity);
var latest = taskActivity?.FirstOrDefault();
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
if (lastProgressAt >= threshold)
continue;
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
continue;
var silentFor = now - lastProgressAt;
await activityRepository.AddAsync(new ActivityEvent
{
Type = StalledActivityType,
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
TaskId = task.Id
}, ct);
await notificationService.CreateAsync(
"task_stalled",
$"Task hängt: {task.Title}",
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
"iris",
task.Id,
ct);
flaggedCount++;
}
if (flaggedCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return flaggedCount;
}
/// <summary>
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
/// </summary>
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList();
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
var backlog = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in allTasks)
{
var dto = MapToDtoWithChildren(task, allTasks, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": backlog.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: backlog.Add(dto); break;
}
}
backlog.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(backlog, inProgress, review, blocked, done);
}
private static DashboardTaskDto MapToDtoWithChildren(
WorkTask task,
IReadOnlyList<WorkTask> allTasks,
IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks
.Where(candidate => candidate.ParentTaskId == task.Id)
.OrderByDescending(candidate => candidate.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(entry => entry.TaskId == task.Id)
.OrderByDescending(entry => entry.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
task.ParentTaskId.HasValue || task.IsAgentTask);
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
return TaskService.BuildMasterBoard(allTasks, activity);
}
private static string BuildActivityMessage(
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
}
private static string FormatDuration(TimeSpan duration)
=> duration.ToString(@"dd\.hh\:mm\:ss");
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
if (duration.TotalHours >= 1)
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
}
+1 -14
View File
@@ -214,13 +214,7 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
=> await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default)
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
+111 -5
View File
@@ -422,6 +422,19 @@ public sealed class TaskService(
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return BuildMasterBoard(all, activity);
}
/// <summary>
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
/// damit nichts unsichtbar wird.
/// </summary>
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
{
var ids = all.Select(t => t.Id).ToHashSet();
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
@@ -431,7 +444,10 @@ public sealed class TaskService(
foreach (var task in all)
{
var dto = MapToDtoWithChildren(task, all, activity);
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
if (!isMaster) continue;
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
switch (task.State.ToLowerInvariant())
{
case "backlog": offen.Add(dto); break;
@@ -490,6 +506,78 @@ public sealed class TaskService(
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
}
/// <summary>
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
/// </summary>
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.ExpectedFrom = null;
return await UpdateTaskStatusInternalAsync(
task,
TaskStateHelper.ToStateString(TaskState.Done),
caller,
"review",
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
ct);
}
/// <summary>
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
/// </summary>
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.ToStateString(TaskState.InProgress);
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
target = TaskStateHelper.ToStateString(TaskState.InProgress);
var trimmed = comment.Trim();
await activityRepo.AddAsync(new ActivityEvent
{
Type = "review_changes_requested",
Message = $"Änderung angefordert von {caller}: {trimmed}",
TaskId = task.Id
}, ct);
task.ExpectedFrom = "iris";
var result = await UpdateTaskStatusInternalAsync(
task, target, caller, "review",
$"Review zurückgegeben von {caller} → {target}", ct);
await notificationService.CreateAsync(
"task_changes_requested",
$"Änderung angefordert: {task.Title}",
trimmed,
"iris",
task.Id,
ct);
return result;
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{
var normalizedHours = Math.Max(1, staleHours);
@@ -507,28 +595,46 @@ public sealed class TaskService(
.ToList();
}
/// <summary>
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
/// </summary>
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.UpdatedAt)
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{
var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList();
}
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
{
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
var childDtos = includeChildren
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
: null;
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
};
}
+3 -2
View File
@@ -23,8 +23,9 @@
"RefreshTokenExpirationDays": 7
},
"TaskRecovery": {
"StaleHours": 2,
"IntervalMinutes": 30
"StalledMinutes": 40,
"IntervalMinutes": 10,
"StaleHours": 2
},
"AllowedHosts": "*"
}
+12 -3
View File
@@ -2,6 +2,15 @@ name: nexus
services:
postgres:
image: postgres:17-alpine
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
command:
- postgres
- -c
- archive_mode=off
- -c
- archive_command=
restart: always
deploy:
resources:
@@ -50,7 +59,7 @@ services:
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
# Initial owner password is generated once at first seed and then lives only in the DB.
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-}
@@ -59,7 +68,7 @@ services:
- host.docker.internal:host-gateway
depends_on:
postgres:
condition: service_started
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
@@ -106,7 +115,7 @@ services:
- "127.0.0.1:18880:80"
depends_on:
api:
condition: service_started
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
+16 -21
View File
@@ -68,8 +68,8 @@ Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren
│ │
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
└──────────────┬──────────────────────────────┬────────────────────┘
host.docker.internal:18789 │
│ (Gateway loopback/lan)
openclaw-gateway-bao:18789 │
│ (internes Docker-DNS)
▼ │
┌──────────────────────────────┐ │
│ OpenClaw Gateway Container │ │
@@ -199,7 +199,7 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
```
POST /api/v1/operations/snapshot
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
→ POST http://host.docker.internal:18789/tools/invoke
→ POST http://openclaw-gateway-bao:18789/tools/invoke
Authorization: Bearer <Gateway-Password>
```
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
### 5.2 Docker-Netzwerk & Gateway-Bind
**Aktuelles Problem:**
**Aktueller Stand (2026-07-09):**
```
compose.yaml:
api:
@@ -228,32 +228,27 @@ compose.yaml:
- host.docker.internal:host-gateway
networks:
- nexus
- openclaw_default ← API-Container ist im Gateway-Netzwerk
- openclaw_default
Gateway-Konfiguration:
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
gateway.bind: "lan"
Nexus-Konfiguration:
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
```
**Ergebnis:**
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
```json5
// openclaw.json
{
gateway: {
bind: "lan" // war "loopback"
}
}
```
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
Produktive Einstellung:
```yaml
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
```
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
### 5.3 MCP-artige Integration: Bewertung
+13 -13
View File
@@ -290,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
```yaml
api:
extra_hosts:
- host.docker.internal:host-gateway
environment:
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
networks:
- nexus
- openclaw_default
```
The API container:
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
- Has `extra_hosts` configured for `host.docker.internal`
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
- Does not depend on a published host port for the Gateway
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
### Known Issue: Gateway Bind = loopback
### Resolved Routing Issue (2026-07-09)
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
| Scenario | Works? | Why |
|----------|--------|-----|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
The Gateway must listen on its container interface (`gateway.bind: "lan"`):
```json5
{
@@ -325,8 +325,8 @@ The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it onl
**Test command (from Nexus API container):**
```bash
curl -s http://host.docker.internal:18789/health
# Expected: 200 if gateway bind is lan/container IP is reachable
curl -s http://openclaw-gateway-bao:18789/
# Expected: HTTP 200 from inside nexus-api-1
```
### Required .env Vars for Nexus
+9
View File
@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html;
index index.html;
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
+8 -146
View File
@@ -1,152 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Activity } from '@lucide/vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useOperationsStore } from './stores/operations'
import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue'
/**
* App — nur noch Router-Einstieg + Toasts.
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
* und umschließt alle Seiten außer dem Login.
*/
import { RouterView } from 'vue-router'
import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const activeView = computed(() => {
if (route.name === 'Settings') return 'Settings'
if (route.name === 'ProjectDetail') return 'ProjectDetail'
return String(route.name ?? 'Dashboard')
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
}
const navigate = (label: string) => {
mobileNavOpen.value = false
return router.push(routePaths[label] ?? '/dashboard')
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => {
if (route.name === 'Dashboard') return true
if (route.meta?.standalone) return true
return false
})
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
})
</script>
<template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell">
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileNavOpen"
:queued-tasks="store.snapshot.metrics.queuedTasks"
:incidents="store.snapshot.metrics.incidents"
@navigate="navigate"
/>
<main>
<AppHeader
:connected="store.connected"
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
/>
<section class="content">
<RouterView v-if="standaloneViews" />
<template v-else>
<div class="page-heading">
<div>
<span class="eyebrow">MISSION CONTROL</span>
<h1>{{ activeView }}</h1>
<p>System overview and operational intelligence across Noveria.</p>
</div>
<button class="refresh" @click="store.refresh()">
<Activity :size="15" :class="{ spin: store.loading }" />
Refresh
</button>
</div>
<ModuleView
:view="activeView"
:snapshot="store.snapshot"
:routing="store.routing"
@create-project="store.createProject"
@create-task="store.createTask"
@update-task-state="store.updateTaskState"
/>
</template>
</section>
</main>
<ToastContainer />
</div>
<RouterView />
<ToastContainer />
</template>
<style scoped>
.shell {
display: flex;
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
}
</style>
+108 -80
View File
@@ -83,8 +83,11 @@
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
background:
radial-gradient(1100px 700px at 12% -10%, rgba(79, 124, 255, 0.10), transparent 60%),
radial-gradient(1000px 700px at 95% 8%, rgba(181, 87, 246, 0.09), transparent 60%),
var(--space-0, #050410);
color: var(--tx, #ece9ff);
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
margin: 0;
@@ -92,6 +95,10 @@ body {
min-height: 100vh;
}
h1, h2, h3, .font-display {
font-family: 'Space Grotesk', 'Manrope', sans-serif;
}
/* Nexus overrides for existing CSS variables used in dashboard */
:root {
--nx-bg: #080a0f;
@@ -126,9 +133,9 @@ body {
display: flex;
flex-direction: column;
padding: 22px 14px 14px;
border-right: 1px solid #1a1e27;
background: rgba(9, 11, 16, 0.94);
backdrop-filter: blur(18px);
border-right: 1px solid var(--line);
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
backdrop-filter: blur(14px);
}
.brand {
@@ -143,15 +150,16 @@ body {
height: 35px;
display: grid;
place-items: center;
border: 1px solid #443d7c;
border-radius: 10px;
background: linear-gradient(145deg, #241f44, #12121f);
color: #b8adff;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
border: none;
border-radius: 11px;
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.brand strong {
display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
letter-spacing: 0.14em;
}
@@ -178,36 +186,38 @@ body {
gap: 10px;
border: 0;
padding: 9px 10px;
border-radius: 7px;
border-radius: 10px;
background: transparent;
color: #8991a1;
color: var(--tx-2);
font-size: 12px;
text-align: left;
cursor: pointer;
}
.nav button:hover,
.nav button.active {
color: #ececf5;
background: var(--nx-accent-soft);
.nav button:hover {
color: var(--tx);
background: rgba(124, 108, 255, 0.08);
}
.nav button.active {
box-shadow: inset 2px 0 var(--nx-accent);
color: #fff;
background: linear-gradient(90deg, rgba(124, 108, 255, 0.22), rgba(124, 108, 255, 0.04));
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, 0.25);
}
.nav button i {
margin-left: auto;
padding: 1px 6px;
border: 1px solid #343947;
border: 1px solid var(--line-2);
border-radius: 8px;
background: rgba(124, 108, 255, 0.10);
font-size: 9px;
font-style: normal;
}
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid #1b1f28;
border-top: 1px solid var(--line);
padding-top: 10px;
}
@@ -240,10 +250,12 @@ body {
height: 31px;
display: grid;
place-items: center;
border-radius: 50%;
background: #28243f;
color: #bcb3ff;
border-radius: 10px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
font-size: 10px;
font-weight: 700;
}
main {
@@ -256,9 +268,9 @@ main {
align-items: center;
justify-content: space-between;
padding: 0 30px;
border-bottom: 1px solid #191d25;
background: rgba(8, 10, 15, 0.68);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--line);
background: rgba(8, 6, 20, 0.5);
backdrop-filter: blur(14px);
}
.search {
@@ -266,19 +278,20 @@ main {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border: 1px solid #202530;
border-radius: 7px;
color: #6f7889;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 11px;
background: rgba(124, 108, 255, 0.06);
color: var(--tx-3);
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 2px 5px;
border: 1px solid #2c313d;
border: 1px solid var(--line-2);
border-radius: 4px;
color: #606979;
color: var(--tx-3);
font-size: 9px;
}
@@ -293,15 +306,20 @@ main {
gap: 6px;
align-items: center;
font-size: 10px;
color: #8c95a5;
font-weight: 600;
color: var(--tx-2);
border: 1px solid var(--line-2);
background: rgba(124, 108, 255, 0.07);
border-radius: 20px;
padding: 4px 11px;
}
.connection.live {
color: var(--nx-green);
color: var(--st-work);
}
.connection.preview {
color: #e6b75d;
color: var(--st-queue);
}
.ask,
@@ -309,13 +327,20 @@ main {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border: 1px solid #37315e;
border-radius: 7px;
background: #18152a;
color: #c4bbff;
padding: 8px 13px;
border: none;
border-radius: 10px;
background: var(--grad);
color: #fff;
font-weight: 600;
box-shadow: var(--glow-purple);
font-size: 10px;
cursor: pointer;
transition: filter .16s;
}
.ask:hover {
filter: brightness(1.08);
}
.content {
@@ -331,7 +356,7 @@ main {
.eyebrow,
.kicker {
color: #7065c8;
color: var(--a-mid);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
@@ -351,9 +376,10 @@ h1 {
}
.refresh-btn {
border-color: var(--nx-line);
background: var(--nx-panel);
color: #a5adba;
background: rgba(124, 108, 255, 0.07);
border: 1px solid var(--line-2);
box-shadow: none;
color: var(--tx-2);
}
.spin {
@@ -370,7 +396,7 @@ h1 {
display: none;
border: 0;
background: transparent;
color: #aaa4e7;
color: var(--tx-2);
}
/* ── Keep existing module/layout styles for non-dashboard pages ── */
@@ -383,9 +409,10 @@ h1 {
.metrics article,
.panel {
border: 1px solid var(--nx-line);
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
border-radius: 9px;
border: 1px solid var(--line);
background: var(--glass);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
.metrics article {
@@ -393,7 +420,7 @@ h1 {
}
.metrics span {
color: #717a8a;
color: var(--tx-3);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
@@ -407,12 +434,12 @@ h1 {
}
.metrics small {
color: #687181;
color: var(--tx-3);
font-size: 9px;
}
.metrics small.up {
color: #55c995;
color: var(--st-work);
}
.dashboard-grid {
@@ -435,7 +462,7 @@ h1 {
align-items: center;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 1px solid #1d222c;
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
@@ -446,7 +473,7 @@ h1 {
.panel-head button {
border: 0;
background: transparent;
color: #8e96a5;
color: var(--tx-2);
font-size: 9px;
}
@@ -457,18 +484,18 @@ h1 {
}
.badge.positive {
color: var(--nx-green);
background: rgba(81, 212, 154, 0.1);
color: var(--st-work);
background: rgba(61, 220, 151, 0.12);
}
.badge.warning {
color: #e7b660;
background: rgba(231, 182, 96, 0.1);
color: var(--st-queue);
background: rgba(251, 191, 36, 0.12);
}
.badge.negative {
color: #e16e75;
background: rgba(225, 110, 117, 0.1);
color: var(--st-block);
background: rgba(251, 113, 133, 0.12);
}
.runtime-row {
@@ -483,9 +510,10 @@ h1 {
height: 45px;
display: grid;
place-items: center;
border-radius: 9px;
color: #ad9fff;
background: var(--nx-accent-soft);
border-radius: var(--r-sm);
color: var(--a-mid);
background: var(--grad-soft);
border: 1px solid var(--line-2);
}
.runtime-main strong,
@@ -517,7 +545,7 @@ h1 {
width: 3px;
min-height: 5px;
border-radius: 3px;
background: linear-gradient(#927fff, #443b7c);
background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
}
.model {
@@ -526,11 +554,11 @@ h1 {
align-items: center;
gap: 9px;
padding: 12px 2px;
border-bottom: 1px solid #1b2029;
border-bottom: 1px solid var(--line);
}
.model > span:last-child {
color: #687181;
color: var(--tx-3);
font-size: 8px;
}
@@ -538,16 +566,16 @@ h1 {
width: 6px;
height: 6px;
border-radius: 50%;
background: #657083;
background: var(--st-idle);
}
.status-dot.online {
background: var(--nx-green);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
background: var(--st-work);
box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
}
.status-dot.offline {
background: #e16e75;
background: var(--st-block);
}
.project {
@@ -556,7 +584,7 @@ h1 {
align-items: center;
gap: 11px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
border-bottom: 1px solid var(--line);
}
.project-letter {
@@ -564,9 +592,9 @@ h1 {
height: 31px;
display: grid;
place-items: center;
border: 1px solid #353047;
border: 1px solid var(--line-2);
border-radius: 7px;
color: #a99cf5;
color: var(--a-mid);
font-size: 10px;
}
@@ -581,7 +609,7 @@ h1 {
}
.project b {
color: #838c9c;
color: var(--tx-2);
font-size: 9px;
}
@@ -590,14 +618,14 @@ h1 {
margin-top: 8px;
overflow: hidden;
border-radius: 4px;
background: #242936;
background: var(--space-3);
}
.progress i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff);
background: var(--grad);
}
.event {
@@ -605,7 +633,7 @@ h1 {
grid-template-columns: auto 1fr;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
border-bottom: 1px solid var(--line);
}
.event > span {
@@ -613,19 +641,19 @@ h1 {
height: 6px;
margin-top: 4px;
border-radius: 50%;
background: #657083;
background: var(--st-idle);
}
.event > span.runtime {
background: var(--nx-green);
background: var(--st-work);
}
.event > span.deploy {
background: #8b7cf6;
background: var(--a-mid);
}
.event > span.security {
background: #e5ad52;
background: var(--st-queue);
}
.placeholder {
@@ -639,7 +667,7 @@ h1 {
.placeholder svg {
margin-bottom: 18px;
color: #8074d8;
color: var(--a-mid);
}
.placeholder h2 {
+90
View File
@@ -37,6 +37,14 @@
--st-block: #fb7185;
--st-idle: #6b6796;
/* ── Agent/Role Semantic Colors ───────────────────── */
--clr-iris: #c084fc;
--clr-bao: #60a5fa;
--clr-agent: #6ee7b7;
--clr-review: #fdba74;
--clr-stale: #fda4af;
--clr-other: #fb923c;
/* ── Glows ────────────────────────────────────────── */
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
@@ -53,6 +61,38 @@
--sidebar-w: 248px;
--topbar-h: 62px;
--rail-w: 360px;
/* ── Legacy-Aliasse (v1-Views) → V2-Tokens ────────────
Ältere Views konsumieren noch die v1-Variablennamen.
Nicht anfassen: --border/--accent (shadcn-HSL-Tripel,
werden als hsl(var(--…)) konsumiert). */
--nx-bg: var(--space-1);
--nx-panel: var(--glass);
--nx-panel-soft: var(--glass-2);
--nx-line: var(--line);
--nx-muted: var(--tx-3);
--nx-accent: var(--a-mid);
--nx-accent-soft: rgba(124, 108, 255, 0.10);
--nx-green: var(--st-work);
--nx-text: var(--tx);
--nx-text-dim: var(--tx-2);
--panel: var(--glass);
--card-color: var(--glass);
--surface: var(--space-2);
--surface-raised: var(--space-3);
--accent-soft: rgba(124, 108, 255, 0.10);
--accent-secondary: var(--a-purple);
--text-primary: var(--tx);
--text-secondary: var(--tx-2);
--text-muted: var(--tx-3);
--text-dim: var(--tx-3);
/* Agent semantic colors (legacy aliases) */
--nx-iris: var(--clr-iris);
--nx-bao: var(--clr-bao);
--nx-agent: var(--clr-agent);
--nx-review: var(--clr-review);
--nx-stale: var(--clr-stale);
}
/* ── Glass card utility ────────────────────────────── */
@@ -108,3 +148,53 @@
/* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
/* ── Button Variants (nexus-token-basiert) ──────────── */
.nexus-btn-gradient {
border: none;
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.nexus-btn-gradient:hover { opacity: .85; transform: translateY(-1px); }
.nexus-btn-gradient:active { transform: translateY(0); }
.nexus-btn-icon {
display: grid;
place-items: center;
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-icon:hover { background: rgba(124,108,255,.10); color: var(--tx); }
.nexus-btn-ghost-subtle {
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-ghost-subtle:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.nexus-btn-danger {
border: 1px solid rgba(251,113,133,.3);
background: rgba(251,113,133,.12);
color: var(--st-block);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s;
}
.nexus-btn-danger:hover { background: rgba(251,113,133,.22); }
-741
View File
@@ -1,741 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
createProject: [name: string]
createTask: [title: string, priority: string]
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() {
if (agentsLoading.value) return
agentsLoading.value = true
agents.value = await store.fetchAgents()
agentsLoading.value = false
}
async function loadPendingApprovals() {
if (!canModerateApprovals.value) {
pendingApprovals.value = []
pendingApprovalsError.value = ''
return
}
pendingApprovalsLoading.value = true
pendingApprovalsError.value = ''
try {
pendingApprovals.value = await store.fetchPendingApprovals()
} catch (e) {
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
} finally {
pendingApprovalsLoading.value = false
}
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
if (v === 'Task Board') void loadPendingApprovals()
})
watch(canModerateApprovals, (value) => {
if (props.view !== 'Task Board') return
if (value) {
void loadPendingApprovals()
return
}
pendingApprovals.value = []
pendingApprovalsError.value = ''
})
const newProject = ref('')
const newTask = ref('')
const message = ref('')
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
const chatPending = ref(false)
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
localStorage.setItem('nexus-conversation-id', conversationId.value)
// Task editing state
const editingTaskId = ref<string | null>(null)
// Task approval / rejection state
const approvingTaskId = ref<string | null>(null)
const taskActionError = ref('')
async function handleApproveTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.approveTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally {
approvingTaskId.value = null
}
}
async function handleRejectTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.rejectTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally {
approvingTaskId.value = null
}
}
// Task deletion state
const deletingTaskId = ref<string | null>(null)
const deleteError = ref('')
async function confirmDeleteTask(id: string) {
deleteError.value = ''
try {
await store.deleteTask(id)
deletingTaskId.value = null
} catch (e) {
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
}
}
function cancelDeleteTask() {
deletingTaskId.value = null
deleteError.value = ''
}
const editTaskTitle = ref('')
const editTaskPriority = ref('')
const editTaskProjectId = ref<string | null>(null)
// Activity filtering and pagination
const activityTypeFilter = ref('')
const activitySort = ref('newest')
const activityPage = ref(1)
const activityPageSize = 20
const activityTotalPages = ref(1)
const activityTotalCount = ref(0)
const columns = computed(() =>
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
const availableTypes = computed(() => {
const types = new Set(props.snapshot.activity.map(e => e.type))
return Array.from(types)
})
const filteredActivity = computed(() => {
let items = [...props.snapshot.activity]
if (activityTypeFilter.value) {
items = items.filter(e => e.type === activityTypeFilter.value)
}
if (activitySort.value === 'oldest') {
items.reverse()
}
const total = items.length
activityTotalCount.value = total
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
const start = (activityPage.value - 1) * activityPageSize
return items.slice(start, start + activityPageSize)
})
watch(activityTypeFilter, () => { activityPage.value = 1 })
watch(activitySort, () => { activityPage.value = 1 })
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
editingTaskId.value = task.id
editTaskTitle.value = task.title
editTaskPriority.value = task.priority
editTaskProjectId.value = task.projectId ?? null
}
async function saveEditTask(id: string) {
try {
await store.updateTask(id, {
title: editTaskTitle.value.trim() || undefined,
priority: editTaskPriority.value || undefined,
projectId: editTaskProjectId.value || undefined,
})
editingTaskId.value = null
} catch (e) {
console.error('Failed to update task', e)
}
}
function cancelEditTask() {
editingTaskId.value = null
}
async function sendMessage() {
const value = message.value.trim()
if (!value || chatPending.value) return
chatMessages.value.push({ role: 'owner', content: value })
message.value = ''
chatPending.value = true
try {
const response = await apiFetch('/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
chatMessages.value.push({ role: 'iris', content: payload.content })
} catch (error) {
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
} finally {
chatPending.value = false
}
}
</script>
<template>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
<div v-if="view === 'Projects'" class="module-grid">
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
</article>
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
<span class="kicker">Owner approvals</span>
<h3>Pending approvals</h3>
</div>
<span class="badge">{{ pendingApprovals.length }}</span>
</header>
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue</p>
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
<div v-else class="approval-list">
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
<div>
<strong>{{ task.title }}</strong>
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
</section>
<div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
<article v-for="task in column.items" :key="task.id" class="task-card">
<template v-if="editingTaskId === task.id">
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
<div class="task-edit-row">
<select v-model="editTaskPriority">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Normal">Normal</option>
<option value="Low">Low</option>
</select>
<select v-model="editTaskProjectId">
<option :value="null">No project</option>
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="task-edit-actions">
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
</div>
</template>
<template v-else>
<div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions">
<template v-if="task.state === 'In progress' && canModerateApprovals">
<button
class="task-approve-btn"
title="Approve"
:disabled="approvingTaskId === task.id"
@click="handleApproveTask(task.id)"
><CheckCircle2 :size="13" /></button>
<button
class="task-reject-btn"
title="Reject"
:disabled="approvingTaskId === task.id"
@click="handleRejectTask(task.id)"
><X :size="13" /></button>
</template>
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button
v-if="task.state === 'Done' || task.state === 'Backlog'"
class="task-delete-btn"
title="Delete task"
@click="deletingTaskId = task.id; deleteError = ''"
><Trash2 :size="12" /></button>
</div>
</div>
<h3>{{ task.title }}</h3>
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
</select>
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
</template>
</article>
<div v-if="!column.items.length" class="empty-state">No tasks</div>
</section>
</div>
<div v-else-if="view === 'Agents'" class="module-grid">
<div v-if="agentsLoading" class="loading-agents">Loading agents</div>
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
<Zap v-else :size="22" />
</div>
<div>
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
<h3>{{ agent.name }}</h3>
<p>{{ agent.description || agent.model }}</p>
</div>
<div class="agent-status-group">
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
</div>
</article>
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
</div>
<div v-else-if="view === 'Models'" class="module-list panel">
<div v-for="model in routing" :key="model.model" class="model-detail">
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
</div>
</div>
<div v-else-if="view === 'Activity'" class="activity-panel panel">
<div class="activity-filters">
<div class="filter-group">
<label>Type</label>
<select v-model="activityTypeFilter">
<option value="">All types</option>
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>Sort</label>
<select v-model="activitySort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</div>
</div>
<div class="timeline">
<article v-for="event in filteredActivity" :key="event.message + event.at">
<div :class="['timeline-icon', event.type]">
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
<ShieldAlert v-else :size="15" />
</div>
<div>
<span class="kicker">{{ event.type }}</span>
<h3>{{ event.message }}</h3>
<p>{{ new Date(event.at).toLocaleString() }}</p>
</div>
</article>
</div>
<div v-if="activityTotalPages > 1" class="activity-pagination">
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
</div>
</div>
<div v-else-if="view === 'Settings'" class="settings-redirect">
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
</div>
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
</div>
<!-- Task deletion confirmation dialog -->
<Teleport to="body">
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
<div class="delete-dialog">
<h3>Delete Task?</h3>
<p>This action cannot be undone. The task will be permanently removed.</p>
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
<div class="delete-actions">
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.approval-strip-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.approval-strip-head h3 {
margin: 2px 0 0;
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
}
.approval-strip-note.error {
color: #e16e75;
}
.approval-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.approval-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
font-size: 12px;
}
.approval-actions {
display: flex;
gap: 8px;
}
.task-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.task-card-actions {
display: flex;
gap: 0.15rem;
align-items: center;
}
.task-edit-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s;
}
.task-card:hover .task-edit-btn {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-delete-btn {
opacity: 1;
}
.task-delete-btn:hover {
color: var(--danger, #e74c3c);
}
.task-edit-input {
width: 100%;
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 0.4rem;
}
.task-edit-row {
display: flex;
gap: 0.35rem;
margin-bottom: 0.4rem;
}
.task-edit-row select {
flex: 1;
padding: 0.25rem 0.4rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-primary);
}
.task-edit-actions {
display: flex;
gap: 0.35rem;
}
.task-edit-save {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.task-edit-cancel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.activity-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-group label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.filter-group select {
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-primary);
}
.activity-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.activity-pagination button {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.activity-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.activity-pagination span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.project-card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.project-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.settings-redirect {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
text-decoration: underline;
}
/* Task deletion confirmation */
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.delete-dialog h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.delete-dialog p {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.4;
}
.delete-error {
color: var(--danger, #e74c3c) !important;
font-weight: 600;
}
.delete-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.delete-cancel {
padding: 0.45rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.delete-confirm {
padding: 0.45rem 1rem;
background: var(--danger, #e74c3c);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.delete-confirm:hover {
opacity: 0.9;
}
/* Agent card enhancements */
.agent-status-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
}
.agent-model-tag {
font-size: 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--text-muted);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-agents {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* Task approve/reject buttons */
.task-approve-btn,
.task-reject-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-approve-btn,
.task-card:hover .task-reject-btn {
opacity: 1;
}
.task-approve-btn {
color: var(--success, #27ae60);
}
.task-approve-btn:hover {
color: var(--success, #27ae60);
filter: brightness(1.2);
}
.task-reject-btn {
color: var(--warning, #f39c12);
}
.task-reject-btn:hover {
color: var(--danger, #e74c3c);
}
.task-approve-btn:disabled,
.task-reject-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
/**
* BoardCard — eine Master-Task-Karte im Board.
*
* Zeigt nur Top-Level-Tasks; Child-Tasks der Agenten leben ausklappbar
* IN der Karte (gruppiert nach Agent) statt als eigene Spalten-Karten —
* so bleibt das Board übersichtlich, auch wenn Iris groß zerlegt.
*
* Ball = wer gerade dran ist. Stalled = In-Bearbeitung ohne Aktivität
* seit der Schwelle (Watchdog meldet parallel an Iris).
*/
import { computed, ref } from 'vue'
import { ChevronRight, Check, RotateCcw, Bot, User, AlertTriangle } from '@lucide/vue'
import type { DashboardTaskDto } from '../../stores/tasks'
import { TASK_AGENT_LABELS } from '../../constants/agentPool'
const props = defineProps<{
task: DashboardTaskDto
column: string
canReview: boolean
stallThresholdMin: number
}>()
const emit = defineEmits<{
open: [id: string]
approve: [id: string]
requestChanges: [task: DashboardTaskDto]
dragstart: [e: DragEvent, id: string]
dragend: [e: DragEvent]
}>()
const expanded = ref(false)
const children = computed(() => props.task.childTasks ?? [])
const hasChildren = computed(() => children.value.length > 0)
const totalChildren = computed(() => props.task.childTaskCount ?? children.value.length)
const doneChildren = computed(() =>
props.task.doneChildTaskCount ?? children.value.filter(c => c.state === 'Done').length
)
const progressPct = computed(() =>
totalChildren.value > 0 ? Math.round((doneChildren.value / totalChildren.value) * 100) : 0
)
/* ── Ball: wer ist dran ───────────────────────────── */
const ballAgent = computed(() => {
const s = props.task.state.toLowerCase()
if (s === 'review') return 'bao'
if (s === 'done') return null
if (s === 'backlog') return props.task.expectedFrom || 'iris'
return props.task.expectedFrom || props.task.assignedTo || 'iris'
})
function agentLabel(id?: string | null): string {
if (!id) return '—'
return TASK_AGENT_LABELS[id.toLowerCase()] ?? id
}
function agentClass(id?: string | null): string {
const lower = (id ?? '').toLowerCase()
if (lower === 'iris') return 'is-iris'
if (lower === 'bao') return 'is-bao'
return 'is-agent'
}
/* ── Stalled-Erkennung (rein aus Aktivitätszeit) ──── */
function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
function isStalled(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > props.stallThresholdMin
}
const masterStalled = computed(() => {
if (hasChildren.value) return children.value.some(isStalled)
return isStalled(props.task)
})
/* ── Child-Gruppierung nach Agent ─────────────────── */
const childrenByAgent = computed(() => {
const groups = new Map<string, DashboardTaskDto[]>()
for (const child of children.value) {
const key = child.assignedTo || 'unassigned'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(child)
}
return [...groups.entries()].map(([agent, tasks]) => ({ agent, tasks }))
})
const assigneeInitials = computed(() => {
const unique = new Set(children.value.map(c => c.assignedTo).filter(Boolean) as string[])
if (!unique.size && props.task.assignedTo) unique.add(props.task.assignedTo)
return [...unique].slice(0, 4).map(a => agentLabel(a).replace(/^[^\w]+/, '').slice(0, 2).toUpperCase())
})
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'High'
if (lower === 'low' || lower === 'minor') return 'Low'
return 'Med'
}
function priorityClass(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'prio-high'
if (lower === 'low' || lower === 'minor') return 'prio-low'
return 'prio-med'
}
function childStateLabel(state: string): string {
const map: Record<string, string> = {
'backlog': 'Offen', 'in progress': 'Aktiv', 'review': 'Review', 'blocked': 'Blockiert', 'done': 'Fertig',
}
return map[state.toLowerCase()] ?? state
}
function childStateClass(state: string): string {
const s = state.toLowerCase()
if (s === 'done') return 'cs-done'
if (s === 'blocked') return 'cs-blocked'
if (s === 'review') return 'cs-review'
if (s === 'in progress') return 'cs-active'
return 'cs-backlog'
}
function relTime(date?: string | null): string {
if (!date) return 'keine Aktivität'
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const h = Math.round(mins / 60)
if (h < 24) return `vor ${h} h`
return `vor ${Math.round(h / 24)} d`
}
function toggleExpand(e: MouseEvent) {
e.stopPropagation()
expanded.value = !expanded.value
}
</script>
<template>
<div
class="mcard"
:class="{ 'mcard-blocked': column === 'blocked', 'mcard-stalled': masterStalled }"
draggable="true"
@click="emit('open', task.id)"
@dragstart="emit('dragstart', $event, task.id)"
@dragend="emit('dragend', $event)"
>
<!-- Kopf: Ball + Priorität + Stalled -->
<div class="mcard-top">
<span v-if="ballAgent" class="ball" :class="agentClass(ballAgent)" :title="'Ball bei ' + agentLabel(ballAgent)">
<Bot v-if="ballAgent === 'iris'" :size="11" />
<User v-else-if="ballAgent === 'bao'" :size="11" />
<span v-else class="ball-dot"></span>
{{ agentLabel(ballAgent) }}
</span>
<span class="prio" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="masterStalled" class="stalled-chip" title="Keine Aktivität seit der Schwelle — Iris benachrichtigt">
<AlertTriangle :size="11" /> hängt
</span>
</div>
<!-- Titel -->
<div class="mcard-title">{{ task.title }}</div>
<!-- Fortschritt aus Children -->
<div v-if="hasChildren" class="mcard-progress">
<div class="progress-row">
<button class="expand-btn" :class="{ open: expanded }" @click="toggleExpand" :aria-label="expanded ? 'Einklappen' : 'Ausklappen'">
<ChevronRight :size="14" />
</button>
<span class="progress-text">{{ doneChildren }}/{{ totalChildren }} Teilaufgaben</span>
<div class="avatars">
<span v-for="(ini, i) in assigneeInitials" :key="i" class="avatar-mini">{{ ini }}</span>
</div>
</div>
<div class="progress-track"><div class="progress-fill" :style="{ width: progressPct + '%' }"></div></div>
</div>
<div v-else-if="task.detail" class="mcard-preview">{{ task.detail }}</div>
<!-- Ausgeklappte Children, gruppiert nach Agent -->
<div v-if="expanded && hasChildren" class="children" @click.stop>
<div v-for="group in childrenByAgent" :key="group.agent" class="child-group">
<div class="child-group-head">
<span class="child-agent" :class="agentClass(group.agent)">{{ agentLabel(group.agent) }}</span>
<span class="child-group-count">{{ group.tasks.length }}</span>
</div>
<button
v-for="child in group.tasks"
:key="child.id"
type="button"
class="child-row"
@click.stop="emit('open', child.id)"
>
<span class="child-title">{{ child.title }}</span>
<span class="child-tail">
<span v-if="isStalled(child)" class="child-stalled" title="hängt"><AlertTriangle :size="10" /></span>
<span class="child-state" :class="childStateClass(child.state)">{{ childStateLabel(child.state) }}</span>
</span>
</button>
</div>
</div>
<!-- Review-Aktionen -->
<div v-if="column === 'review' && canReview" class="review-actions" @click.stop>
<button class="rv-approve" @click="emit('approve', task.id)"><Check :size="13" /> Abnehmen</button>
<button class="rv-changes" @click="emit('requestChanges', task)"><RotateCcw :size="13" /> Änderung</button>
</div>
<div class="mcard-meta">
<span>Update {{ relTime(task.lastActivityAt ?? task.updatedAt) }}</span>
</div>
</div>
</template>
<style scoped>
.mcard {
padding: 11px 12px;
border-radius: var(--r-sm, 10px);
background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35));
border: 1px solid var(--line);
cursor: pointer;
transition: transform .15s, box-shadow .2s, border-color .15s;
text-align: left;
width: 100%;
}
.mcard:hover { transform: translateY(-1px); border-color: var(--line-2); box-shadow: 0 8px 24px -6px rgba(0,0,0,.4); }
.mcard-blocked { border-left: 3px solid var(--st-block); }
.mcard-stalled { border-left: 3px solid var(--st-queue); }
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
.ball.is-iris { background: rgba(147,51,234,.16); color: #c084fc; }
.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; }
.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); }
.prio-med { color: var(--st-queue); border-color: var(--st-queue); }
.prio-low { color: var(--a-blue); border-color: var(--a-blue); }
.stalled-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; font-size: 9.5px; font-weight: 600; color: var(--st-queue); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); padding: 1px 6px; border-radius: 20px; }
.mcard-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.mcard-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mcard-progress { margin-top: 9px; }
.progress-row { display: flex; align-items: center; gap: 8px; }
.expand-btn { display: grid; place-items: center; width: 20px; height: 20px; border: none; border-radius: 6px; background: rgba(124,108,255,.08); color: var(--tx-2); cursor: pointer; transition: transform .15s, background .15s; flex: 0 0 auto; }
.expand-btn:hover { background: rgba(124,108,255,.16); color: var(--tx); }
.expand-btn.open { transform: rotate(90deg); }
.progress-text { font-size: 10.5px; color: var(--tx-2); font-family: 'Manrope', sans-serif; }
.avatars { margin-left: auto; display: flex; }
.avatar-mini { width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; background: var(--grad-soft); border: 1px solid var(--space-1); display: grid; place-items: center; font-size: 8px; font-weight: 700; color: var(--tx); font-family: 'JetBrains Mono', monospace; }
.avatar-mini:first-child { margin-left: 0; }
.progress-track { height: 4px; margin-top: 6px; border-radius: 2px; background: var(--space-3); overflow: hidden; }
.progress-fill { height: 100%; border-radius: 2px; background: var(--grad); transition: width .3s; }
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
.child-agent.is-iris { background: rgba(147,51,234,.14); color: #c084fc; }
.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; }
.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); }
.child-title { flex: 1; font-size: 11px; line-height: 1.35; word-break: break-word; }
.child-tail { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.child-stalled { color: var(--st-queue); display: inline-flex; }
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
.cs-review { background: rgba(251,146,60,.14); color: #fdba74; }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
.review-actions { display: flex; gap: 6px; margin-top: 10px; }
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); }
.rv-changes { border: 1px solid rgba(251,146,60,.3); background: rgba(251,146,60,.12); color: #fdba74; }
.rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
</style>
@@ -298,7 +298,7 @@ function avatarLabel() {
.m-av.iris {
background: var(--grad);
color: #fff;
color: var(--tx);
box-shadow: var(--glow-purple);
}
@@ -328,13 +328,13 @@ function avatarLabel() {
font-weight: 600;
border: 1px solid transparent;
}
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.badge-blue { background:rgba(79,124,255,.14); color:var(--a-blue); border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill {
display: inline-flex;
@@ -428,7 +428,7 @@ function avatarLabel() {
}
.m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
background: linear-gradient(90deg, var(--st-work), var(--st-work));
box-shadow: var(--glow-work);
}
@@ -510,7 +510,7 @@ function avatarLabel() {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: #9fe8fb;
color: var(--st-think);
min-height: 72px;
position: relative;
overflow: hidden;
@@ -593,7 +593,7 @@ function avatarLabel() {
.m-model-btn.active {
background: var(--grad);
border: none;
color: #fff;
color: var(--tx);
box-shadow: var(--glow-purple);
}
@@ -157,7 +157,7 @@ defineEmits<{
.nc-av.iris-av {
background: var(--grad);
color: #fff;
color: var(--tx);
box-shadow: var(--glow-purple);
}
@@ -255,7 +255,7 @@ defineEmits<{
}
.node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
background: linear-gradient(90deg, var(--st-work), var(--st-work));
}
/* ── Meta ────────────────────────────────────── */
@@ -159,7 +159,7 @@ defineEmits<{
border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px;
font-weight: 600;
color: #fda4b0;
color: var(--st-block);
cursor: pointer;
transition: background .15s;
font-family: 'Manrope', sans-serif;
@@ -86,7 +86,7 @@ function renderEdges() {
const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--a-blue)"/><stop offset="1" stop-color="var(--a-purple)"/></linearGradient></defs>`
let paths = ''
let pulses = ''
let idCounter = 0
@@ -105,17 +105,17 @@ function renderEdges() {
if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
paths += `<path d="${d}" fill="none" stroke="var(--st-work)" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') {
// Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
pulses += `<circle r="2.8" fill="var(--a-mid)" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else {
// Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
paths += `<path d="${d}" fill="none" stroke="var(--a-mid)" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
}
})
@@ -336,7 +336,7 @@ function handleReset() {
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
color: var(--tx);
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
@@ -123,7 +123,7 @@ watch(
.iris-av :deep(svg) {
width: 18px;
height: 18px;
color: #fff;
color: var(--tx);
}
.iris-name {
@@ -196,7 +196,7 @@ watch(
padding: 24px 0;
}
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
.chat-msg-info.error { color: var(--st-block); font-style: normal; }
.chat-row {
display: flex;
@@ -222,7 +222,7 @@ watch(
.bubble.me {
background: var(--grad);
color: #fff;
color: var(--tx);
border-bottom-right-radius: 5px;
margin-left: auto;
box-shadow: var(--glow-purple);
@@ -313,6 +313,6 @@ watch(
.send :deep(svg) {
width: 17px;
height: 17px;
color: #fff;
color: var(--tx);
}
</style>
@@ -12,7 +12,7 @@ function prioLabel(p: TaskItem['priority']): string {
}
function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
return p === 'high' ? 'var(--st-block)' : p === 'medium' ? 'var(--st-queue)' : 'var(--a-blue)'
}
function dotClass(s: TaskItem['status']): string {
@@ -1,94 +0,0 @@
<script setup lang="ts">
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
defineProps<{
connected: boolean
}>()
defineEmits<{
toggleMobileNav: []
}>()
</script>
<template>
<header class="topbar">
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
<Command :size="19" />
</button>
<div class="search">
<Search :size="16" />
<span>Search operations</span>
<kbd> K</kbd>
</div>
<div class="top-actions">
<span :class="['connection', connected ? 'live' : 'preview']">
<CircleDot :size="13" />
{{ connected ? 'Live' : 'Preview data' }}
</span>
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--nx-panel, #11141b);
}
.mobile-menu { display: none; }
.search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px;
color: var(--nx-text-dim, #6f7889);
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 1px 4px;
border: 1px solid #2a2f3d;
border-radius: 4px;
font-size: 8px;
color: #4a5266;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.connection {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
padding: 4px 9px;
border-radius: 6px;
}
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
.ask {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-radius: 6px;
background: var(--nx-accent, #7b6ef2);
color: #fff;
font-size: 10px;
cursor: pointer;
}
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
}
</style>
@@ -1,216 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { initials } from '../../utils/format'
const props = defineProps<{
activeView: string
mobileNavOpen: boolean
queuedTasks: number
incidents: number
}>()
const emit = defineEmits<{
navigate: [label: string]
}>()
const auth = useAuthStore()
const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle },
{ separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal },
{ label: 'Activity', icon: Activity },
{ label: 'Mobile Chat', icon: MessageSquareText },
]
function onNavigate(label: string) {
emit('navigate', label)
}
async function logout() {
await auth.logout()
await router.replace('/login')
}
</script>
<template>
<aside :class="['sidebar', { open: mobileNavOpen }]">
<div class="brand">
<div class="brand-mark"><Command :size="18" /></div>
<div>
<strong>NEXUS</strong>
<span>Noveria Operations</span>
</div>
</div>
<nav class="nav">
<template v-for="item in navigation" :key="item.label ?? 'sep'">
<div v-if="item.separator" class="nav-separator"></div>
<button
v-else
:class="{ active: activeView === item.label }"
@click="onNavigate(item.label)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
</button>
</template>
</nav>
<div class="sidebar-bottom">
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
<button class="owner" type="button" title="Sign out" @click="logout">
<div class="avatar">{{ ownerInitials }}</div>
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
<LogOut :size="15" />
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 210px;
display: flex;
flex-direction: column;
background: var(--panel, #11141b);
border-right: 1px solid var(--line, #1f2330);
flex-shrink: 0;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 10px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--accent, #7b6ef2);
color: #fff;
}
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
overflow-y: auto;
}
.nav button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
}
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
.nav button i {
margin-left: auto;
background: var(--accent, #7b6ef2);
color: #fff;
font-style: normal;
font-size: 8px;
font-weight: 700;
padding: 1px 5px;
border-radius: 5px;
line-height: 1.4;
}
.nav button i.badge-red {
background: #e16e75;
}
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--nx-line, #1f2330);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
.sidebar-bottom > button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
.owner {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.owner div strong { display: block; font-size: 9px; }
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
.owner:hover > svg:last-child { opacity: 1; }
.avatar {
width: 26px;
height: 26px;
border-radius: 6px;
display: grid;
place-items: center;
background: var(--accent, #7b6ef2);
color: #fff;
font-size: 9px;
font-weight: 700;
}
@media (max-width: 860px) {
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
.sidebar.open { transform: translateX(0); }
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+267 -146
View File
@@ -1,139 +1,180 @@
<script setup lang="ts">
/**
* Sidebar — kompakte Icon-Rail (V2-Shell, alle Seiten)
*
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
* Mobile: als Drawer über mobileOpen/close.
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons'
import { useNotificationStore } from '../../stores/notifications'
import { useLiveSyncStore } from '../../stores/liveSync'
import { railNav, railFooterNav, svg } from '../../composables/icons'
import { initials } from '../../utils/format'
defineProps<{
mobileOpen?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const agentStore = useAgentStore()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
const liveSync = useLiveSyncStore()
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
function isActive(itemRoute?: string): boolean {
if (!itemRoute) return false
if (route.path === itemRoute) return true
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
return route.path.startsWith(itemRoute + '/')
}
/**
* Dynamische Nav-Item-Counts aus den Stores.
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
*/
const dynamicNavigation = computed<NavGroupDef[]>(() => {
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
function navigate(itemRoute?: string) {
if (!itemRoute) return
emit('close')
router.push(itemRoute)
}
switch (item.label) {
case 'Agenten':
case 'Hosts · OpenClaw':
dynamicCount = String(agentStore.agentList.length)
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
}
async function logout() {
await auth.logout()
await router.replace('/login')
}
return {
...item,
count: dynamicCount ?? item.count,
}
}),
}))
const statusLabel = computed(() => {
if (liveSync.connected) return 'Live'
if (liveSync.connecting) return 'Verbinde…'
return 'Polling'
})
</script>
<template>
<aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<aside :class="['rail', { open: mobileOpen }]">
<!-- Brand -->
<div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div>
<div>
<div class="brand-name">NEXUS</div>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<button class="rail-brand" @click="navigate('/dashboard')">
<span class="brand-mark" v-html="svg('command')"></span>
<span class="rail-label brand-label">NEXUS</span>
</button>
<!-- Navigation -->
<nav class="nav">
<NavGroup
v-for="(group, idx) in dynamicNavigation"
:key="idx"
:label="group.group"
:items="group.items"
/>
<nav class="rail-nav v2-scroll">
<button
v-for="item in railNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-dot"
></span>
<span class="rail-label">{{ item.label }}</span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-count"
>{{ notificationStore.unreadCount }}</span>
</button>
</nav>
<!-- Footer -->
<div class="side-foot">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
<div class="rail-foot">
<div class="rail-item static" :title="statusLabel">
<span class="rail-icon">
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
</span>
<span class="rail-label dim">{{ statusLabel }}</span>
</div>
<button
v-for="item in railFooterNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span class="rail-label">{{ item.label }}</span>
</button>
<div class="rail-owner">
<span class="avatar">{{ ownerInitials }}</span>
<span class="rail-label owner-label">
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
</span>
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 248px;
flex: 0 0 248px;
height: 100vh;
.rail {
position: absolute;
inset: 0 auto 0 0;
width: 68px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
position: relative;
z-index: 2;
overflow: hidden;
transition: width .18s ease, box-shadow .18s ease;
z-index: 100;
}
.side-top {
.rail:hover {
width: 232px;
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
}
/* Labels: unsichtbar bis die Rail expandiert */
.rail-label {
opacity: 0;
white-space: nowrap;
transition: opacity .14s ease .04s;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.rail:hover .rail-label,
.rail.open .rail-label {
opacity: 1;
}
/* ── Brand ── */
.rail-brand {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 18px 16px;
gap: 13px;
padding: 15px;
border: none;
background: transparent;
cursor: pointer;
}
.brand-mark {
width: 38px;
height: 38px;
flex: 0 0 38px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--grad);
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.brand-mark :deep(svg) {
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
color: #fff;
}
.brand-name {
.brand-label {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 17px;
font-size: 16px;
letter-spacing: .14em;
line-height: 1;
color: var(--tx);
}
.brand-sub {
font-size: 10.5px;
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
/* ── Nav ── */
.rail-nav {
flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
gap: 3px;
padding: 6px 12px;
overflow-y: auto;
overflow-x: hidden;
}
.side-foot {
padding: 12px;
border-top: 1px solid var(--line);
.rail-item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
gap: 13px;
height: 42px;
padding: 0 13px;
flex: 0 0 auto;
border: none;
border-radius: 11px;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background .15s;
transition: background .15s, color .15s;
}
.side-foot:hover {
background: rgba(124,108,255,.06);
.rail-item:not(.static):hover {
background: rgba(124, 108, 255, .08);
color: var(--tx);
}
.sidebar-close {
display: none;
.rail-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.rail-item.static {
cursor: default;
}
.sidebar.open {
transform: translateX(0);
}
.rail-icon {
width: 18px;
height: 18px;
flex: 0 0 18px;
display: grid;
place-items: center;
opacity: .9;
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 18px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
z-index: 1;
}
.rail-icon :deep(svg) {
width: 18px;
height: 18px;
}
.sidebar-close:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.rail-item .rail-label {
flex: 1;
}
.sidebar-close :deep(svg) {
width: 18px;
height: 18px;
}
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
.rail-dot {
position: absolute;
left: 24px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--st-block);
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
}
.rail-count {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(251, 113, 133, .16);
border: 1px solid rgba(251, 113, 133, .35);
color: var(--st-block);
}
/* ── Footer ── */
.rail-foot {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 12px 10px;
border-top: 1px solid var(--line);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--st-work);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.rail-label.dim {
color: var(--tx-3);
font-size: 12px;
}
.rail-owner {
display: flex;
align-items: center;
gap: 13px;
padding: 7px 5px 2px;
}
.avatar {
width: 34px;
height: 34px;
flex: 0 0 34px;
border-radius: 10px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
font-size: 12px;
color: var(--tx);
flex-shrink: 0;
}
.owner-info {
min-width: 0;
.owner-label {
flex: 1;
display: flex;
flex-direction: column;
}
.owner-name {
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.owner-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize;
}
.logout-btn {
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
padding: 6px;
border-radius: 8px;
display: grid;
place-items: center;
}
.logout-btn:hover {
color: var(--st-block);
background: rgba(251, 113, 133, .1);
}
.logout-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Mobile: Drawer ── */
@media (max-width: 767px) {
.rail {
position: fixed;
width: 232px;
transform: translateX(-100%);
transition: transform .22s ease;
}
.rail.open {
transform: translateX(0);
}
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>()
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* ActivityTimeline — Wiederverwendbare Aktivitäts-Feed-Komponente
*
* Standardisierte Timeline-Darstellung für Task-Aktivität,
* Agent-Aktivität und allgemeine Ereignis-Feeds.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
export interface ActivityEntry {
id?: string
message: string
timestamp?: string
type?: string
}
withDefaults(defineProps<{
entries: ActivityEntry[]
loading?: boolean
emptyLabel?: string
}>(), {
emptyLabel: 'Noch keine Aktivität',
})
function formatDate(date?: string | null): string {
if (!date) return ''
return new Date(date).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
</script>
<template>
<div class="activity-timeline">
<!-- Loading -->
<div v-if="loading" class="activity-empty">Lade</div>
<!-- Empty -->
<div v-else-if="!entries.length" class="activity-empty">{{ emptyLabel }}</div>
<!-- Entries -->
<article v-for="(entry, index) in entries" :key="entry.id ?? index" class="activity-item">
<div class="activity-dot"></div>
<div class="activity-body">
<div class="activity-message">{{ entry.message }}</div>
<div v-if="entry.timestamp" class="activity-time">
{{ formatDate(entry.timestamp) }}
</div>
</div>
</article>
</div>
</template>
<style scoped>
.activity-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.activity-empty {
font-size: 11px;
color: var(--tx-3);
font-style: italic;
padding: 8px;
text-align: center;
font-family: 'Manrope', sans-serif;
}
.activity-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 999px;
margin-top: 5px;
background: var(--grad);
box-shadow: 0 0 0 4px rgba(124, 108, 255, .12);
flex-shrink: 0;
}
.activity-body {
min-width: 0;
}
.activity-message {
color: var(--tx);
font-size: 12.5px;
line-height: 1.45;
}
.activity-time {
color: var(--tx-3);
font-size: 10.5px;
margin-top: 4px;
}
</style>
+97
View File
@@ -1,4 +1,11 @@
<script setup lang="ts">
/**
* Badge — Nexus V2 Status- und Label-Badge
*
* Erweitert: zusätzliche nexus-token-basierte Varianten für
* Agenten-Rollen, Status-Pills und Prioritäten.
* Original shadcn-Varianten bleiben erhalten.
*/
import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
@@ -8,10 +15,25 @@ const badgeVariants = cva(
{
variants: {
variant: {
// Original shadcn
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
// Nexus V2 — Token-basierte Varianten (Farben aus nexus-tokens.css)
work: 'nexus-badge-work',
think: 'nexus-badge-think',
blocked: 'nexus-badge-blocked',
queue: 'nexus-badge-queue',
done: 'nexus-badge-done',
review: 'nexus-badge-review',
iris: 'nexus-badge-iris',
bao: 'nexus-badge-bao',
agent: 'nexus-badge-agent',
priorityHigh: 'nexus-badge-prio-high',
priorityMed: 'nexus-badge-prio-med',
priorityLow: 'nexus-badge-prio-low',
},
},
defaultVariants: {
@@ -35,3 +57,78 @@ const props = withDefaults(defineProps<Props>(), {
<slot />
</span>
</template>
<style scoped>
/* Nexus V2 Token-basierte Badge-Varianten */
.nexus-badge-work {
border-color: rgba(61, 220, 151, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-think {
border-color: rgba(52, 214, 245, .25);
background: rgba(52, 214, 245, .1);
color: var(--st-think);
}
.nexus-badge-blocked {
border-color: rgba(251, 113, 133, .25);
background: rgba(244, 63, 94, .12);
color: var(--st-block);
}
.nexus-badge-queue {
border-color: rgba(251, 191, 36, .25);
background: rgba(251, 191, 36, .12);
color: var(--st-queue);
}
.nexus-badge-done {
border-color: rgba(34, 197, 94, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-review {
border-color: rgba(249, 115, 22, .25);
background: rgba(249, 115, 22, .12);
color: var(--clr-review);
}
.nexus-badge-iris {
border-color: rgba(147, 51, 234, .25);
background: rgba(147, 51, 234, .12);
color: var(--clr-iris);
}
.nexus-badge-bao {
border-color: rgba(59, 130, 246, .25);
background: rgba(59, 130, 246, .12);
color: var(--clr-bao);
}
.nexus-badge-agent {
border-color: rgba(16, 185, 129, .2);
background: rgba(16, 185, 129, .12);
color: var(--clr-agent);
}
.nexus-badge-prio-high {
border-color: var(--st-block);
background: transparent;
color: var(--st-block);
}
.nexus-badge-prio-med {
border-color: var(--st-queue);
background: transparent;
color: var(--st-queue);
}
.nexus-badge-prio-low {
border-color: var(--a-blue);
background: transparent;
color: var(--a-blue);
}
</style>
+40 -2
View File
@@ -1,16 +1,54 @@
<script setup lang="ts">
/**
* Card — Nexus V2 glass-panel Karte
*
* Verwendet nexus-tokens.css als Single Source of Truth.
* Hintergrund-kompatibel mit shadcn-Card-Props.
* Wrapped content in .glass-panel styles aus tokens.css.
*/
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
/** Variante: 'glass' (default) | 'raised' | 'subtle' */
variant?: 'glass' | 'raised' | 'subtle'
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
})
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<div
:class="cn(
'rounded-xl border',
{
'glass-panel': variant === 'glass',
'card-raised': variant === 'raised',
'card-subtle': variant === 'subtle',
},
props.class,
)"
>
<slot />
</div>
</template>
<style scoped>
/* glass-panel ist in nexus-tokens.css definiert */
.card-raised {
background: var(--glass-2);
border: 1px solid var(--line-2);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
.card-subtle {
background: rgba(255, 255, 255, .02);
border: 1px solid var(--line);
border-radius: var(--r);
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
/**
* EmptyState — Standardisierte Leerzustands-Anzeige
*
* Konsistente Darstellung für "keine Daten"-Zustände im Dashboard,
* TaskBoard und allen V2-Views.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Icon-Klasse (optional, z.B. für SVG-Nutzung) */
icon?: string
/** Primärer Text */
title?: string
/** Sekundärer Erklärungstext */
description?: string
/** Kompakte Darstellung (inline-flex) */
compact?: boolean
}>(), {
title: 'Keine Einträge',
compact: false,
})
</script>
<template>
<div class="empty-state" :class="{ compact }">
<div v-if="icon" class="empty-icon" v-html="icon"></div>
<slot name="icon">
<div v-if="!icon" class="empty-icon-default">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="4" y="6" width="20" height="16" rx="3" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="10" y1="16" x2="15" y2="16" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</div>
</slot>
<div v-if="!compact" class="empty-title">{{ title }}</div>
<div v-if="description" class="empty-desc">{{ description }}</div>
<div v-if="$slots.action" class="empty-action">
<slot name="action" />
</div>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 12px;
text-align: center;
}
.empty-state.compact {
flex-direction: row;
gap: 8px;
padding: 8px 12px;
justify-content: flex-start;
}
.empty-icon,
.empty-icon-default {
color: var(--tx-3);
opacity: 0.5;
margin-bottom: 8px;
}
.compact .empty-icon,
.compact .empty-icon-default {
margin-bottom: 0;
flex-shrink: 0;
}
.empty-icon-default {
display: grid;
place-items: center;
}
.empty-title {
font-size: 12px;
font-weight: 600;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
}
.compact .empty-title {
font-size: 11px;
font-weight: 400;
font-style: italic;
color: var(--tx-3);
}
.empty-desc {
margin-top: 4px;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
max-width: 280px;
line-height: 1.4;
}
.empty-action {
margin-top: 12px;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* PageHeading — Standardisierter Seiten-Header für V2 Views
*
* Bietet konsistentes grad-text Styling, Eyebrow/Subtitle und Action-Slot.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Gradient-Text Überschrift */
title: string
/** Kleiner Eyebrow-Text über der Überschrift (violett) */
eyebrow?: string
/** Subtitle unter der Überschrift */
subtitle?: string
}>(), {})
</script>
<template>
<header class="page-heading">
<div>
<p v-if="eyebrow || $slots.eyebrow" class="eyebrow">
<slot name="eyebrow">{{ eyebrow }}</slot>
</p>
<h1><span class="grad-text">{{ title }}</span></h1>
<p v-if="subtitle" class="board-subtitle">{{ subtitle }}</p>
<slot name="meta" />
</div>
<div v-if="$slots.actions" class="heading-actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-heading h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
letter-spacing: -0.02em;
}
.eyebrow {
color: var(--a-mid);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
margin: 0 0 4px 0;
}
.board-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
}
.grad-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.heading-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* SectionHeader — Titelzeile für Sektionen/Spalten im Dashboard V2
*
* Kapselt wiederholtes Muster: dot + label + count aus TaskBoard-Spalten
* und Iris-Panel-Sektionen. Verwendet ausschließlich Token-Farben.
*/
withDefaults(defineProps<{
label: string
/** Status-Farbe des Dots: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao' */
dotColor?: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao'
/** Numerischer Count (rechts) */
count?: number
/** Variante: 'column' (Spalten-Header) oder 'section' (Iris-Panel) */
variant?: 'column' | 'section'
}>(), {
variant: 'column',
})
function dotColorVar(color: string): string {
const map: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
queue: 'var(--st-queue)',
review: 'var(--st-queue)', // orange-ähnlich
iris: 'var(--a-purple)',
bao: 'var(--a-blue)',
}
return map[color] || 'var(--st-idle)'
}
function dotRingVar(color: string): string {
const map: Record<string, string> = {
work: 'rgba(61, 220, 151, .25)',
think: 'rgba(52, 214, 245, .25)',
idle: 'rgba(107, 103, 150, .25)',
block: 'rgba(251, 113, 133, .25)',
queue: 'rgba(251, 191, 36, .25)',
review: 'rgba(251, 146, 60, .25)',
iris: 'rgba(181, 87, 246, .25)',
bao: 'rgba(79, 124, 255, .25)',
}
return map[color] || 'rgba(107, 103, 150, .25)'
}
</script>
<template>
<div class="section-header" :class="variant">
<span
class="section-dot"
:style="{
background: dotColorVar(dotColor || 'idle'),
boxShadow: `0 0 0 2px ${dotRingVar(dotColor || 'idle')}`,
}"
></span>
<span class="section-label">{{ label }}</span>
<span v-if="count !== undefined" class="section-count">{{ count }}</span>
<slot />
</div>
</template>
<style scoped>
.section-header {
display: flex;
align-items: center;
gap: 8px;
}
.section-header.column {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.section-header.section {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.section-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--tx-2);
font-family: 'Space Grotesk', sans-serif;
}
.section-header.section .section-label {
font-size: 11px;
}
.section-count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 10px;
background: var(--glass-2);
color: var(--tx-2);
border: 1px solid var(--line);
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* StatusDot — Nexus V2 animierter Status-Indikator
*
* Kapselt die pulse-keyframes aus nexus-tokens.css.
* Status-Farben und Pulse-Animationen stammen ausschließlich aus Tokens.
*
* Props:
* status 'work' | 'think' | 'idle' | 'block' | 'queue'
* size 'sm' (8px) | 'md' (9px) | 'lg' (12px), default 'md'
* pulse Animation aktiv (default: true für work/think/block)
* label Text-Label rechts neben dem Dot (optional)
*/
withDefaults(defineProps<{
status: 'work' | 'think' | 'idle' | 'block' | 'queue'
size?: 'sm' | 'md' | 'lg'
pulse?: boolean
label?: string
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-dot-wrapper">
<span
class="dot"
:class="[status, size, { pulse: pulse !== false }]"
:aria-label="label || status"
role="status"
></span>
<span v-if="label" class="dot-label">{{ label }}</span>
</span>
</template>
<style scoped>
.status-dot-wrapper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
display: inline-block;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.sm { width: 7px; height: 7px; }
.dot.md { width: 9px; height: 9px; }
.dot.lg { width: 12px; height: 12px; }
/* Farben aus nexus-tokens.css — Single Source of Truth */
.dot.work { background: var(--st-work); }
.dot.think { background: var(--st-think); }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
.dot.queue { background: var(--st-queue); }
/* Pulse-Animationen (Keyframes in tokens.css definiert) */
.dot.work.pulse { animation: pulse-work 1.8s infinite; box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
.dot.think.pulse { animation: pulse-think 1.8s infinite; box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
.dot.block.pulse { animation: pulse-block 1.4s infinite; box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
.dot-label {
font-size: 12.5px;
font-weight: 600;
color: var(--tx-2);
white-space: nowrap;
}
</style>
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: {
icon: CheckCircle,
color: '#22c55e',
bg: 'rgba(34, 197, 94, 0.10)',
color: 'var(--st-work)',
bg: 'rgba(61, 220, 151, 0.10)',
},
error: {
icon: XCircle,
color: '#ef4444',
bg: 'rgba(239, 68, 68, 0.10)',
color: 'var(--st-block)',
bg: 'rgba(251, 113, 133, 0.10)',
},
info: {
icon: Info,
color: '#3b82f6',
bg: 'rgba(59, 130, 246, 0.10)',
color: 'var(--a-blue)',
bg: 'rgba(79, 124, 255, 0.10)',
},
}
</script>
@@ -76,7 +76,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto;
color: #e8eaf0;
color: var(--tx);
font-size: 12.5px;
line-height: 1.4;
}
@@ -106,7 +106,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
color: var(--tx-3);
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
@@ -114,7 +114,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
.toast-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
color: var(--tx);
}
/* Transition animations */
+17 -7
View File
@@ -4,25 +4,35 @@ import { type VariantProps, cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
// shadcn-original
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
// Nexus V2 — Token-basierte Varianten
/** CTA / primäre Aktion — Gradient */
gradient: 'nexus-btn-gradient',
/** Icon-Only — quadratischer Button ohne Text */
icon: 'nexus-btn-icon',
/** Ghost thin — minimal hover */
ghostSubtle: 'nexus-btn-ghost-subtle',
/** Danger/Blocker — rote Akzent-Aktion */
danger: 'nexus-btn-danger',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
iconSm: 'h-7 w-7 rounded-md',
pill: 'h-7 px-4 rounded-full text-xs',
},
},
defaultVariants: {
+13
View File
@@ -0,0 +1,13 @@
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'
export { default as StatusDot } from './StatusDot.vue'
export { default as PageHeading } from './PageHeading.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ActivityTimeline } from './ActivityTimeline.vue'
export { default as SectionHeader } from './SectionHeader.vue'
export { default as Input } from './Input.vue'
export { default as Textarea } from './Textarea.vue'
export { default as Select } from './Select.vue'
export { default as Dialog } from './Dialog.vue'
export { default as ToastContainer } from './ToastContainer.vue'
export { Button } from './button'
+21 -35
View File
@@ -25,6 +25,10 @@ export const icons: Record<string, string> = {
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
@@ -50,40 +54,22 @@ export interface NavGroupDef {
}
/**
* Navigation structure matching NEXUS.nav from agents.js
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
* unten im Fußbereich (railFooterNav).
*/
export const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
],
},
export const railNav: NavItemDef[] = [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs', route: '/docs' },
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
{ icon: 'shield', label: 'Security', route: '/security' },
]
export const railFooterNav: NavItemDef[] = [
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
]
+113 -27
View File
@@ -1,49 +1,79 @@
<script setup lang="ts">
/**
* NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column)
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
* NexusLayout — gemeinsame Shell für ALLE Seiten
*
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
* Content bekommt die volle restliche Fläche:
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
* - alle anderen: scrollbarer Container mit Seiten-Padding
*
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
*/
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useLiveSyncStore } from '../stores/liveSync'
import { useNotificationStore } from '../stores/notifications'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
import { svg } from '../composables/icons'
const dashboardStore = useDashboardStore()
const route = useRoute()
const auth = useAuthStore()
const liveSync = useLiveSyncStore()
const notificationStore = useNotificationStore()
/* ── Mobile Sidebar State ───────────────────────── */
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
/* ── Mobile Drawer ─────────────────────────────── */
const mobileMenuOpen = ref(false)
function closeMobileMenu() {
mobileMenuOpen.value = false
}
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
function onVisibilityChange() {
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
liveSync.connect(liveUser.value)
}
}
function onOnline() {
liveSync.reconnectNow()
}
onMounted(() => {
liveSync.connect(liveUser.value)
notificationStore.startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('online', onOnline)
})
onUnmounted(() => {
liveSync.disconnect()
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('online', onOnline)
})
</script>
<template>
<div class="nexus-layout">
<GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop -->
<div
v-if="mobileMenuOpen"
class="mobile-backdrop"
@click="closeMobileMenu"
></div>
<div class="rail-slot">
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
</div>
<!-- Mobile: Hamburger + Backdrop -->
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
<main class="nexus-main">
<Topbar
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content">
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
<RouterView />
</div>
</main>
@@ -59,6 +89,15 @@ function closeMobileMenu() {
position: relative;
}
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
und kann expandieren, ohne den Content zu verschieben. */
.rail-slot {
width: 68px;
flex: 0 0 68px;
position: relative;
z-index: 2;
}
.nexus-main {
flex: 1;
display: flex;
@@ -70,19 +109,62 @@ function closeMobileMenu() {
.nexus-content {
flex: 1;
overflow: hidden;
min-height: 0;
}
.nexus-content.full-bleed {
overflow: hidden;
display: flex;
flex-direction: column;
}
.nexus-content.full-bleed > :deep(*) {
flex: 1;
min-height: 0;
}
.nexus-content.page-scroll {
overflow-y: auto;
padding: 24px 28px 64px;
}
.mobile-toggle,
.mobile-backdrop {
display: none;
}
@media (max-width: 767px) {
.rail-slot {
width: 0;
flex: 0 0 0;
}
.nexus-main {
width: 100%;
}
.mobile-toggle {
display: grid;
place-items: center;
position: fixed;
top: 12px;
left: 12px;
z-index: 90;
width: 40px;
height: 40px;
border: 1px solid var(--line-2);
border-radius: 12px;
background: var(--glass);
backdrop-filter: blur(12px);
color: var(--tx-2);
cursor: pointer;
}
.mobile-toggle :deep(svg) {
width: 19px;
height: 19px;
}
.mobile-backdrop {
display: block;
position: fixed;
@@ -90,5 +172,9 @@ function closeMobileMenu() {
z-index: 99;
background: rgba(0, 0, 0, 0.5);
}
.nexus-content.page-scroll {
padding: 60px 16px 48px;
}
}
</style>
+15 -19
View File
@@ -19,31 +19,27 @@ const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' },
// V2 Dashboard (neues NexusLayout + FlowBoard)
// Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
{
path: '/dashboard',
path: '/',
component: NexusLayout,
children: [
{ path: '', name: 'Dashboard', component: FlowBoard },
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
{ path: 'memory', name: 'Memory', component: MemoryView },
{ path: 'docs', name: 'Docs', component: DocsView },
{ path: 'calendar', name: 'Calendar', component: CalendarView },
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
{ path: 'security', name: 'Security', component: SecurityView },
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
{ path: 'settings', name: 'Settings', component: SettingsView },
],
},
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
{ path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
-172
View File
@@ -1,172 +0,0 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
+49 -10
View File
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
watchdogTimer: null as ReturnType<typeof setInterval> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
forUser: 'bao',
}),
getters: {
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.forUser = forUser
this.controller = new AbortController()
this.startWatchdog()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
if (!this.controller?.signal.aborted) {
console.warn('[liveSync] stream failed', error)
this.error = 'Live updates unavailable'
}
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
if (this.controller?.signal.aborted) return
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
},
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
reconnectNow() {
const forUser = this.forUser
this.disconnect()
this.connect(forUser)
},
startWatchdog() {
if (this.watchdogTimer) return
this.watchdogTimer = setInterval(() => {
if (!this.connected || !this.lastEventAt) return
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
// auch wenn der Browser den fetch-Stream noch für offen hält.
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
if (silentMs > 65000) {
console.warn('[liveSync] heartbeat timeout, reconnecting')
this.reconnectNow()
}
}, 15000)
},
stopWatchdog() {
if (this.watchdogTimer) {
clearInterval(this.watchdogTimer)
this.watchdogTimer = null
}
},
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
},
disconnect() {
this.stopWatchdog()
this.controller?.abort()
this.controller = null
this.connected = false
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
// danach sanft hochstaffeln bis 30s.
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
+36
View File
@@ -30,6 +30,7 @@ export interface DashboardTaskDto {
lastActivityMessage?: string | null
lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null
doneChildTaskCount?: number
childTaskCount?: number
openChildTaskCount?: number
hasVisibleDelegation?: boolean
@@ -215,6 +216,29 @@ export const useTaskStore = defineStore('tasks', {
}
},
/* ── API: Review abnehmen (Review → Done) ─────── */
async approveReview(id: string) {
const res = await apiFetch(`/api/dashboard/tasks/${id}/approve`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
await this.fetchBoard()
},
/* ── API: Änderung anfordern (Review → Zielspalte) ── */
async requestChanges(id: string, comment: string, targetState = 'In progress') {
const res = await apiFetch(`/api/dashboard/tasks/${id}/request-changes`, {
method: 'POST',
body: JSON.stringify({ comment, targetState }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
await this.fetchBoard()
},
/* ── API: Create task ─────────────────────────── */
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
try {
@@ -297,6 +321,18 @@ export const useTaskStore = defineStore('tasks', {
}
},
/* ── API: Kommentar/Aktivität an Task posten ──── */
async postTaskActivity(id: string, message: string, type = 'comment') {
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`, {
method: 'POST',
body: JSON.stringify({ message, type }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
},
/* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true
+34 -34
View File
@@ -143,10 +143,10 @@ function formatModifiedAt(dateStr: string): string {
const statusColor = (status: string): string => {
switch (status) {
case 'Online': return '#51d49a'
case 'Degraded': return '#e5b05e'
case 'Offline': return '#e16e75'
default: return '#7e8799'
case 'Online': return 'var(--st-work)'
case 'Degraded': return 'var(--st-queue)'
case 'Offline': return 'var(--st-block)'
default: return 'var(--tx-3)'
}
}
@@ -612,15 +612,15 @@ onUnmounted(() => {
border: 1px solid var(--line);
border-radius: 7px;
background: var(--panel);
color: #7e8799;
color: var(--tx-3);
font-size: 10.5px;
cursor: pointer;
margin-bottom: 20px;
transition: border-color 0.15s, color 0.15s;
}
.back-link:hover {
border-color: #443d7c;
color: #d8dbe3;
border-color: var(--line-3);
color: var(--tx);
}
.status-message {
@@ -629,11 +629,11 @@ onUnmounted(() => {
justify-content: center;
gap: 8px;
padding: 48px;
color: #7e8799;
color: var(--tx-3);
font-size: 12px;
}
.status-message.error {
color: #e16e75;
color: var(--st-block);
}
.status-message.compact {
padding: 20px;
@@ -660,15 +660,15 @@ onUnmounted(() => {
place-items: center;
border-radius: 12px;
background: rgba(139,124,246,.1);
color: #8b7cf6;
color: var(--a-mid);
flex-shrink: 0;
}
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
.agent-avatar.iris { background: rgba(139,124,246,.15); color: var(--a-mid); }
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: var(--a-blue); }
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: var(--a-blue); }
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: var(--st-queue); }
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: var(--a-purple); }
.agent-avatar.executor { background: rgba(77,246,212,.15); color: var(--st-think); }
.agent-header-info {
flex: 1;
@@ -678,7 +678,7 @@ onUnmounted(() => {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
color: var(--a-mid);
text-transform: uppercase;
margin-bottom: 2px;
}
@@ -686,7 +686,7 @@ onUnmounted(() => {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.agent-status-row {
display: flex;
@@ -702,18 +702,18 @@ onUnmounted(() => {
}
.status-label {
font-size: 11px;
color: #7e8799;
color: var(--tx-3);
display: inline-flex;
align-items: center;
gap: 4px;
}
.status-label.muted { color: #6b7385; }
.status-label.muted { color: var(--tx-3); }
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.status-sep { color: #3d4152; font-size: 11px; }
.status-sep { color: var(--line-2); font-size: 11px; }
.thinking-section {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
margin-bottom: 16px;
overflow: hidden;
@@ -731,11 +731,11 @@ onUnmounted(() => {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
color: var(--a-mid);
}
.section-head h2 {
margin: 2px 0 0;
color: #e8eaf0;
color: var(--tx);
font-size: 13px;
font-weight: 600;
display: inline-flex;
@@ -744,7 +744,7 @@ onUnmounted(() => {
}
.section-note {
margin: 6px 0 0;
color: #6f788b;
color: var(--tx-3);
font-size: 10px;
line-height: 1.45;
max-width: 560px;
@@ -753,10 +753,10 @@ onUnmounted(() => {
width: 6px;
height: 6px;
border-radius: 999px;
background: #6b7385;
background: var(--tx-3);
}
.live-dot.on {
background: #51d49a;
background: var(--st-work);
}
.icon-button {
width: 30px;
@@ -764,7 +764,7 @@ onUnmounted(() => {
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,.03);
color: #9ba3b5;
color: var(--tx-2);
display: grid;
place-items: center;
cursor: pointer;
@@ -790,13 +790,13 @@ onUnmounted(() => {
.summary-card small {
display: block;
margin-top: 7px;
color: #6f788b;
color: var(--tx-3);
font-size: 9.5px;
line-height: 1.4;
}
.summary-row span {
display: block;
color: #6f788b;
color: var(--tx-3);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
@@ -804,7 +804,7 @@ onUnmounted(() => {
}
.summary-row p {
margin: 0;
color: #cbd0dc;
color: var(--tx-2);
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
@@ -821,20 +821,20 @@ onUnmounted(() => {
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #6f788b;
color: var(--tx-3);
font-size: 10px;
}
.type-pill {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff;
color: var(--a-mid);
background: rgba(123,110,242,.08);
font-size: 9px;
}
.thinking-item p {
margin: 0;
color: #cbd0dc;
color: var(--tx-2);
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
+35 -35
View File
@@ -41,7 +41,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6',
color: '#7c6cff',
icon: 'bot',
},
{
@@ -50,7 +50,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['Coding', 'Development', 'Builds'],
color: '#4d8cf6',
color: '#4f7cff',
icon: 'code',
},
{
@@ -59,7 +59,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Infrastructure Engineer',
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
tags: ['Infrastructure', 'Deployment', 'Docker'],
color: '#4da8f6',
color: '#4f7cff',
icon: 'server',
},
{
@@ -68,7 +68,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['QA', 'Security', 'Code Review'],
color: '#f6a84d',
color: '#fbbf24',
icon: 'shield',
},
{
@@ -77,7 +77,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#8b4df6',
color: '#b557f6',
icon: 'search',
},
{
@@ -86,7 +86,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Docker', 'VPS'],
color: '#4df6d4',
color: '#34d6f5',
icon: 'terminal',
},
]
@@ -161,7 +161,7 @@ function enrichAgent(item: any): AgentCard {
role: item.role || fallback?.role || 'Agent',
description: item.description || fallback?.description || 'OpenClaw agent',
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
color: fallback?.color ?? '#7e8799',
color: fallback?.color ?? '#6f6aa0',
icon: fallback?.icon ?? 'bot',
model: item.model,
statusLabel: item.statusLabel,
@@ -327,19 +327,19 @@ onMounted(loadMissionControl)
place-items: center;
border-radius: 11px;
background: rgba(139, 124, 246, 0.1);
color: #8b7cf6;
color: var(--a-mid);
flex-shrink: 0;
}
.header-text h1 {
margin: 0 0 2px;
font-size: 22px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.header-subtitle {
margin: 0;
font-size: 11px;
color: #7e8799;
color: var(--tx-3);
}
.gateway-chip {
margin-left: auto;
@@ -349,24 +349,24 @@ onMounted(loadMissionControl)
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 7px;
color: #9ba3b5;
color: var(--tx-2);
font-size: 10px;
}
.gateway-chip.ok {
color: #51d49a;
color: var(--st-work);
border-color: rgba(81, 212, 154, .25);
}
.gateway-chip.warn {
color: #e5b05e;
color: var(--st-queue);
border-color: rgba(229, 176, 94, .28);
}
.gateway-chip.error {
color: #f29b9b;
color: var(--st-block);
border-color: rgba(242, 155, 155, .3);
}
.load-error {
margin-bottom: 14px;
color: #e5b05e;
color: var(--st-queue);
font-size: 11px;
}
.gateway-warning,
@@ -376,19 +376,19 @@ onMounted(loadMissionControl)
border-radius: 11px;
border: 1px solid rgba(229, 176, 94, .24);
background: rgba(229, 176, 94, .08);
color: #f1d7aa;
color: var(--st-queue);
font-size: 11px;
line-height: 1.5;
}
.empty-state {
border-color: var(--line);
background: rgba(255,255,255,.03);
color: #aab2c3;
color: var(--tx-2);
}
.empty-state h3 {
margin: 0 0 6px;
font-size: 14px;
color: #e8eaf0;
color: var(--tx);
}
.empty-state p {
margin: 0;
@@ -451,7 +451,7 @@ onMounted(loadMissionControl)
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
border-radius: var(--r);
flex-shrink: 0;
}
@@ -464,19 +464,19 @@ onMounted(loadMissionControl)
margin: 0 0 1px;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.card-role {
display: block;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
letter-spacing: 0.02em;
}
.card-desc {
font-size: 10.5px;
color: #7e8799;
color: var(--tx-3);
line-height: 1.5;
margin: 0 0 10px;
flex: 1;
@@ -486,7 +486,7 @@ onMounted(loadMissionControl)
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #8a92a5;
color: var(--tx-2);
font-size: 9.5px;
min-width: 0;
}
@@ -494,19 +494,19 @@ onMounted(loadMissionControl)
width: 7px;
height: 7px;
border-radius: 999px;
background: #6b7385;
background: var(--tx-3);
flex-shrink: 0;
}
.runtime-dot.on {
background: #51d49a;
background: var(--st-work);
}
.runtime-dot.connected { background: #51d49a; }
.runtime-dot.thinking { background: #79aaff; }
.runtime-dot.blocked { background: #f87171; }
.runtime-dot.stale { background: #f59e0b; }
.runtime-dot.error { background: #f29b9b; }
.runtime-dot.unsupported { background: #e5b05e; }
.runtime-dot.ready { background: #6b7385; }
.runtime-dot.connected { background: var(--st-work); }
.runtime-dot.thinking { background: var(--a-blue); }
.runtime-dot.blocked { background: var(--st-block); }
.runtime-dot.stale { background: var(--st-queue); }
.runtime-dot.error { background: var(--st-block); }
.runtime-dot.unsupported { background: var(--st-queue); }
.runtime-dot.ready { background: var(--tx-3); }
.runtime-model {
margin-left: auto;
max-width: 46%;
@@ -514,12 +514,12 @@ onMounted(loadMissionControl)
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: #6f788b;
color: var(--tx-3);
}
.runtime-detail {
margin: 0 0 10px;
min-height: 28px;
color: #7e8799;
color: var(--tx-3);
font-size: 10px;
line-height: 1.4;
}
@@ -561,7 +561,7 @@ onMounted(loadMissionControl)
border-top: 1px solid var(--line);
font-size: 9px;
font-weight: 600;
color: #6b7385;
color: var(--tx-3);
text-transform: uppercase;
letter-spacing: 0.06em;
transition: color 0.15s;
+20 -20
View File
@@ -219,7 +219,7 @@ onMounted(() => {
}
.calendar-panel {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 16px;
}
@@ -235,7 +235,7 @@ onMounted(() => {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.calendar-status {
display: flex;
@@ -243,11 +243,11 @@ onMounted(() => {
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
color: var(--tx-3);
font-size: 11px;
}
.calendar-status.error {
color: #e16e75;
color: var(--st-block);
}
/* Upcoming jobs */
@@ -268,25 +268,25 @@ onMounted(() => {
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
color: var(--a-mid);
background: rgba(139,124,246,.1);
}
.upcoming-item-info strong {
display: block;
font-size: 11px;
margin-bottom: 2px;
color: #e8eaf0;
color: var(--tx);
}
.upcoming-item-schedule {
display: block;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
margin-bottom: 2px;
}
.upcoming-item-next {
display: block;
font-size: 9px;
color: #a99cf5;
color: var(--a-mid);
font-weight: 600;
}
@@ -310,19 +310,19 @@ onMounted(() => {
border-radius: 6px;
}
.job-item-icon.status-completed {
color: #27ae60;
color: var(--st-work);
background: rgba(39, 174, 96, 0.12);
}
.job-item-icon.status-running {
color: #3498db;
color: var(--a-blue);
background: rgba(52, 152, 219, 0.12);
}
.job-item-icon.status-failed {
color: #e74c3c;
color: var(--st-block);
background: rgba(231, 76, 60, 0.12);
}
.job-item-icon.status-idle {
color: #7e8799;
color: var(--tx-3);
background: rgba(126, 135, 153, 0.12);
}
.job-item-info {
@@ -332,19 +332,19 @@ onMounted(() => {
display: block;
font-size: 11px;
margin-bottom: 2px;
color: #e8eaf0;
color: var(--tx);
}
.job-item-id {
display: block;
font-size: 9px;
color: #6b7385;
color: var(--tx-3);
margin-bottom: 2px;
font-family: monospace;
}
.job-item-schedule {
display: block;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
}
.job-item-meta {
display: flex;
@@ -355,7 +355,7 @@ onMounted(() => {
}
.job-item-meta small {
font-size: 8px;
color: #6b7385;
color: var(--tx-3);
text-align: right;
}
.job-status-badge {
@@ -368,19 +368,19 @@ onMounted(() => {
}
.job-status-badge.status-completed {
background: rgba(39, 174, 96, 0.15);
color: #27ae60;
color: var(--st-work);
}
.job-status-badge.status-running {
background: rgba(52, 152, 219, 0.15);
color: #3498db;
color: var(--a-blue);
}
.job-status-badge.status-failed {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
color: var(--st-block);
}
.job-status-badge.status-idle {
background: rgba(126, 135, 153, 0.15);
color: #7e8799;
color: var(--tx-3);
}
.spin {
animation: spin 1s linear infinite;
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks'
import { useLiveSyncStore } from '../../stores/liveSync'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
const chatStore = useChatStore()
const dashboardStore = useDashboardStore()
const taskStore = useTaskStore()
const liveSyncStore = useLiveSyncStore()
const router = useRouter()
const {
@@ -70,7 +68,6 @@ onMounted(() => {
dashboardStore.startPolling()
taskStore.startPolling()
taskStore.startBoardPolling()
liveSyncStore.connect()
})
onUnmounted(() => {
@@ -79,7 +76,6 @@ onUnmounted(() => {
dashboardStore.stopPolling()
taskStore.stopPolling()
taskStore.stopBoardPolling()
liveSyncStore.disconnect()
})
</script>
+32 -32
View File
@@ -203,14 +203,14 @@ onMounted(loadDocs)
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: #6f7889;
color: var(--tx-3);
}
.docs-search-bar input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
color: var(--tx);
font: inherit;
font-size: 13px;
}
@@ -222,13 +222,13 @@ onMounted(loadDocs)
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: #8991a1;
color: var(--tx-2);
}
.docs-filter-group select {
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
color: var(--tx);
font: inherit;
font-size: 12px;
cursor: pointer;
@@ -241,7 +241,7 @@ onMounted(loadDocs)
}
.memory-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 8px;
max-height: 640px;
@@ -250,7 +250,7 @@ onMounted(loadDocs)
.memory-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
color: var(--a-mid);
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
@@ -264,7 +264,7 @@ onMounted(loadDocs)
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
color: var(--tx);
text-align: left;
cursor: pointer;
font: inherit;
@@ -280,7 +280,7 @@ onMounted(loadDocs)
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
color: var(--a-mid);
background: rgba(139,124,246,.1);
}
.memory-file-info strong {
@@ -305,41 +305,41 @@ onMounted(loadDocs)
}
.doc-category-badge.cat-phases {
background: rgba(139,124,246,.12);
color: #a99cf5;
color: var(--a-mid);
}
.doc-category-badge.cat-skills {
background: rgba(81,212,154,.1);
color: #51d49a;
color: var(--st-work);
}
.doc-category-badge.cat-workspace {
background: rgba(229,176,94,.1);
color: #e5b05e;
color: var(--st-queue);
}
.doc-category-badge.cat-nexus {
background: rgba(109,159,230,.1);
color: #6d9fe6;
color: var(--a-blue);
}
.doc-category-badge.cat-nexus-phases {
background: rgba(225,110,117,.1);
color: #e16e75;
color: var(--st-block);
}
.doc-type-tag {
font-size: 8px;
padding: 1px 5px;
border: 1px solid #343947;
border: 1px solid var(--line-2);
border-radius: 4px;
color: #8991a1;
color: var(--tx-2);
}
.memory-file-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
}
.memory-content {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 24px;
min-height: 480px;
@@ -363,7 +363,7 @@ onMounted(loadDocs)
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
margin-top: 4px;
}
.memory-back-btn {
@@ -374,13 +374,13 @@ onMounted(loadDocs)
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
color: var(--tx-2);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.memory-back-btn:hover {
color: #e8eaf0;
color: var(--tx);
background: var(--accent-soft);
}
.memory-status {
@@ -389,11 +389,11 @@ onMounted(loadDocs)
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
color: var(--tx-3);
font-size: 11px;
}
.memory-status.error {
color: #e16e75;
color: var(--st-block);
}
.memory-empty-state {
display: flex;
@@ -402,27 +402,27 @@ onMounted(loadDocs)
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
color: var(--tx-3);
}
.memory-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
color: var(--tx-2);
}
.memory-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
color: var(--tx-3);
}
.memory-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
color: var(--tx-2);
}
.memory-rendered :deep(h1),
.memory-rendered :deep(h2),
.memory-rendered :deep(h3) {
color: #e8eaf0;
color: var(--tx);
margin: 1.2em 0 0.5em;
}
.memory-rendered :deep(h1) { font-size: 1.3rem; }
@@ -438,8 +438,8 @@ onMounted(loadDocs)
.memory-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--nx-line);
background: var(--space-2);
border: 1px solid var(--line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -447,7 +447,7 @@ onMounted(loadDocs)
padding: 0;
}
.memory-rendered :deep(a) {
color: #a99cf5;
color: var(--a-mid);
text-decoration: none;
}
.memory-rendered :deep(a:hover) {
@@ -461,11 +461,11 @@ onMounted(loadDocs)
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--nx-line);
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
color: #e8eaf0;
color: var(--tx);
}
.spin {
animation: spin 1s linear infinite;
+29 -29
View File
@@ -202,7 +202,7 @@ onMounted(loadIncidents)
}
.incident-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 8px;
max-height: 640px;
@@ -211,7 +211,7 @@ onMounted(loadIncidents)
.incident-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
color: var(--a-mid);
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
@@ -225,7 +225,7 @@ onMounted(loadIncidents)
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
color: var(--tx);
text-align: left;
cursor: pointer;
font: inherit;
@@ -243,19 +243,19 @@ onMounted(loadIncidents)
border-radius: 6px;
}
.incident-file-icon.sev-critical {
color: #e74c3c;
color: var(--st-block);
background: rgba(231, 76, 60, 0.12);
}
.incident-file-icon.sev-major {
color: #e67e22;
color: var(--st-queue);
background: rgba(230, 126, 34, 0.12);
}
.incident-file-icon.sev-minor {
color: #f1c40f;
color: var(--st-queue);
background: rgba(241, 196, 15, 0.12);
}
.incident-file-icon.sev-unknown {
color: #7e8799;
color: var(--tx-3);
background: rgba(126, 135, 153, 0.12);
}
.incident-file-info strong {
@@ -269,7 +269,7 @@ onMounted(loadIncidents)
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
margin-bottom: 4px;
}
.incident-file-excerpt {
@@ -278,7 +278,7 @@ onMounted(loadIncidents)
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 9px;
color: #6b7385;
color: var(--tx-3);
line-height: 1.5;
}
.severity-badge {
@@ -291,23 +291,23 @@ onMounted(loadIncidents)
}
.severity-badge.sev-critical {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
color: var(--st-block);
}
.severity-badge.sev-major {
background: rgba(230, 126, 34, 0.15);
color: #e67e22;
color: var(--st-queue);
}
.severity-badge.sev-minor {
background: rgba(241, 196, 15, 0.15);
color: #f1c40f;
color: var(--st-queue);
}
.severity-badge.sev-unknown {
background: rgba(126, 135, 153, 0.15);
color: #7e8799;
color: var(--tx-3);
}
.incident-content {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 24px;
min-height: 480px;
@@ -335,7 +335,7 @@ onMounted(loadIncidents)
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
}
.incident-back-btn {
display: flex;
@@ -345,13 +345,13 @@ onMounted(loadIncidents)
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
color: var(--tx-2);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.incident-back-btn:hover {
color: #e8eaf0;
color: var(--tx);
background: var(--accent-soft);
}
.incident-status {
@@ -360,11 +360,11 @@ onMounted(loadIncidents)
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
color: var(--tx-3);
font-size: 11px;
}
.incident-status.error {
color: #e16e75;
color: var(--st-block);
}
.incident-empty-state {
display: flex;
@@ -373,27 +373,27 @@ onMounted(loadIncidents)
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
color: var(--tx-3);
}
.incident-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
color: var(--tx-2);
}
.incident-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
color: var(--tx-3);
}
.incident-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
color: var(--tx-2);
}
.incident-rendered :deep(h1),
.incident-rendered :deep(h2),
.incident-rendered :deep(h3) {
color: #e8eaf0;
color: var(--tx);
margin: 1.2em 0 0.5em;
}
.incident-rendered :deep(h1) { font-size: 1.3rem; }
@@ -409,8 +409,8 @@ onMounted(loadIncidents)
.incident-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--nx-line);
background: var(--space-2);
border: 1px solid var(--line);
overflow-x: auto;
}
.incident-rendered :deep(pre code) {
@@ -418,7 +418,7 @@ onMounted(loadIncidents)
padding: 0;
}
.incident-rendered :deep(a) {
color: #a99cf5;
color: var(--a-mid);
text-decoration: none;
}
.incident-rendered :deep(a:hover) {
@@ -432,11 +432,11 @@ onMounted(loadIncidents)
}
.incident-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--nx-line);
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.incident-rendered :deep(strong) {
color: #e8eaf0;
color: var(--tx);
}
.spin {
animation: spin 1s linear infinite;
+25 -25
View File
@@ -227,22 +227,22 @@ onMounted(loadMemories)
border-radius: 8px;
background: var(--panel);
margin-bottom: 16px;
color: #6f7889;
color: var(--tx-3);
}
.memory-search-bar input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: #e8eaf0;
color: var(--tx);
font: inherit;
font-size: 13px;
}
.memory-search-bar kbd {
padding: 2px 5px;
border: 1px solid #2c313d;
border: 1px solid var(--line-2);
border-radius: 4px;
color: #606979;
color: var(--tx-3);
font-size: 9px;
}
.memory-layout {
@@ -253,7 +253,7 @@ onMounted(loadMemories)
}
.memory-sidebar {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 8px;
max-height: 640px;
@@ -262,7 +262,7 @@ onMounted(loadMemories)
.memory-list-header {
font-size: 9px;
font-weight: 700;
color: #7065c8;
color: var(--a-mid);
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 8px 6px;
@@ -276,7 +276,7 @@ onMounted(loadMemories)
border: none;
border-radius: 7px;
background: transparent;
color: #e8eaf0;
color: var(--tx);
text-align: left;
cursor: pointer;
font: inherit;
@@ -292,7 +292,7 @@ onMounted(loadMemories)
display: grid;
place-items: center;
border-radius: 6px;
color: #a99cf5;
color: var(--a-mid);
background: rgba(139,124,246,.1);
}
.memory-file-info strong {
@@ -307,7 +307,7 @@ onMounted(loadMemories)
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
line-height: 1.5;
}
.memory-file-excerpt {
@@ -318,7 +318,7 @@ onMounted(loadMemories)
}
.memory-content {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 24px;
min-height: 480px;
@@ -342,7 +342,7 @@ onMounted(loadMemories)
align-items: center;
gap: 4px;
font-size: 9px;
color: #7e8799;
color: var(--tx-3);
}
.memory-back-btn {
display: flex;
@@ -352,13 +352,13 @@ onMounted(loadMemories)
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: #8991a1;
color: var(--tx-2);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
}
.memory-back-btn:hover {
color: #e8eaf0;
color: var(--tx);
background: var(--accent-soft);
}
.memory-status {
@@ -367,11 +367,11 @@ onMounted(loadMemories)
justify-content: center;
gap: 8px;
padding: 32px;
color: #7e8799;
color: var(--tx-3);
font-size: 11px;
}
.memory-status.error {
color: #e16e75;
color: var(--st-block);
}
.memory-empty-state {
display: flex;
@@ -380,27 +380,27 @@ onMounted(loadMemories)
justify-content: center;
text-align: center;
min-height: 360px;
color: #6b7385;
color: var(--tx-3);
}
.memory-empty-state h3 {
margin: 12px 0 6px;
font-size: 14px;
color: #a5adba;
color: var(--tx-2);
}
.memory-empty-state p {
margin: 0;
font-size: 10px;
color: #7e8799;
color: var(--tx-3);
}
.memory-rendered {
font-size: 12px;
line-height: 1.7;
color: #d0d4dd;
color: var(--tx-2);
}
.memory-rendered :deep(h1),
.memory-rendered :deep(h2),
.memory-rendered :deep(h3) {
color: #e8eaf0;
color: var(--tx);
margin: 1.2em 0 0.5em;
}
.memory-rendered :deep(h1) { font-size: 1.3rem; }
@@ -416,8 +416,8 @@ onMounted(loadMemories)
.memory-rendered :deep(pre) {
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--nx-line);
background: var(--space-2);
border: 1px solid var(--line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -425,7 +425,7 @@ onMounted(loadMemories)
padding: 0;
}
.memory-rendered :deep(a) {
color: #a99cf5;
color: var(--a-mid);
text-decoration: none;
}
.memory-rendered :deep(a:hover) {
@@ -439,11 +439,11 @@ onMounted(loadMemories)
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--nx-line);
border-top: 1px solid var(--line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
color: #e8eaf0;
color: var(--tx);
}
.spin {
animation: spin 1s linear infinite;
+14 -18
View File
@@ -2,12 +2,10 @@
import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications'
import { useLiveSyncStore } from '../stores/liveSync'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore()
const router = useRouter()
const liveSyncStore = useLiveSyncStore()
const sortedNotifications = computed(() => {
return [...store.notifications].sort(
@@ -26,10 +24,10 @@ function typeIcon(type: string): string {
function typeColor(type: string): string {
switch (type) {
case 'task_assigned': return '#4d8cf6'
case 'task_review': return '#f6a84d'
case 'task_blocked': return '#e16e75'
default: return '#7b6ef2'
case 'task_assigned': return '#4f7cff'
case 'task_review': return '#fbbf24'
case 'task_blocked': return '#fb7185'
default: return '#7c6cff'
}
}
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
onMounted(() => {
store.startListPolling()
liveSyncStore.connect()
})
onUnmounted(() => {
store.stopListPolling()
liveSyncStore.disconnect()
})
</script>
@@ -137,18 +133,18 @@ onUnmounted(() => {
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim, #6f7889);
color: var(--tx-3);
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.mark-all-btn:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
color: #d8dbe3;
background: rgba(124, 108, 255, 0.08);
color: var(--tx);
}
/* ── Empty State ── */
@@ -158,7 +154,7 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
padding: 80px 0;
color: var(--nx-text-dim, #6f7889);
color: var(--tx-3);
gap: 16px;
}
@@ -186,7 +182,7 @@ onUnmounted(() => {
}
.notification-card:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
background: rgba(124, 108, 255, 0.06);
}
.notification-card.unread {
@@ -215,7 +211,7 @@ onUnmounted(() => {
.card-title {
font-size: 12.5px;
color: #d8dbe3;
color: var(--tx);
line-height: 1.4;
}
@@ -226,7 +222,7 @@ onUnmounted(() => {
.card-message {
font-size: 10.5px;
color: var(--nx-text-dim, #6f7889);
color: var(--tx-3);
margin-top: 3px;
line-height: 1.3;
}
@@ -242,12 +238,12 @@ onUnmounted(() => {
.timestamp {
font-size: 9px;
color: var(--nx-text-dim, #6f7889);
color: var(--tx-3);
white-space: nowrap;
}
.arrow {
color: var(--nx-text-dim, #6f7889);
color: var(--tx-3);
opacity: .5;
}
</style>
+15 -15
View File
@@ -221,7 +221,7 @@ onMounted(loadProject)
}
.project-detail-card {
background: var(--surface);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 12px;
padding: 1.5rem;
}
@@ -234,7 +234,7 @@ onMounted(loadProject)
width: 48px;
height: 48px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
background: linear-gradient(135deg, var(--a-mid), var(--accent-secondary));
color: #fff;
font-size: 1.25rem;
font-weight: 600;
@@ -273,7 +273,7 @@ onMounted(loadProject)
}
.btn-icon {
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 8px;
padding: 0.5rem;
cursor: pointer;
@@ -282,8 +282,8 @@ onMounted(loadProject)
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--nx-accent-soft);
color: var(--nx-accent);
background: rgba(124, 108, 255, 0.10);
color: var(--a-mid);
}
.btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
@@ -294,7 +294,7 @@ onMounted(loadProject)
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 8px;
font-size: 1.1rem;
color: var(--text-primary);
@@ -304,7 +304,7 @@ onMounted(loadProject)
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--text-primary);
@@ -321,7 +321,7 @@ onMounted(loadProject)
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: var(--nx-accent);
background: var(--a-mid);
color: #fff;
border: none;
border-radius: 6px;
@@ -335,7 +335,7 @@ onMounted(loadProject)
padding: 0.4rem 0.8rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
@@ -343,7 +343,7 @@ onMounted(loadProject)
.progress-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
border-top: 1px solid var(--line-2);
}
.progress-header {
display: flex;
@@ -361,7 +361,7 @@ onMounted(loadProject)
.progress-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
background: linear-gradient(90deg, var(--a-mid), var(--accent-secondary));
border-radius: 4px;
transition: width 0.3s;
}
@@ -380,7 +380,7 @@ onMounted(loadProject)
padding: 1.5rem;
text-align: center;
background: var(--surface);
border: 1px dashed var(--border);
border: 1px dashed var(--line-2);
border-radius: 8px;
}
.task-list {
@@ -394,7 +394,7 @@ onMounted(loadProject)
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 8px;
}
.task-icon {
@@ -403,7 +403,7 @@ onMounted(loadProject)
.task-icon.done { color: rgb(34, 197, 94); }
.task-icon.blocked { color: rgb(239, 68, 68); }
.task-icon.backlog { color: var(--text-muted); }
.task-icon.in-progress { color: var(--nx-accent); }
.task-icon.in-progress { color: var(--a-mid); }
.task-info {
flex: 1;
}
@@ -441,7 +441,7 @@ onMounted(loadProject)
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border: 1px solid var(--line-2);
border-radius: 12px;
padding: 1.5rem;
max-width: 420px;
+18 -18
View File
@@ -192,7 +192,7 @@ onMounted(loadStatus)
}
.security-card {
border: 1px solid var(--line);
border-radius: 9px;
border-radius: var(--r);
background: var(--panel);
padding: 20px;
display: flex;
@@ -204,27 +204,27 @@ onMounted(loadStatus)
height: 40px;
display: grid;
place-items: center;
border-radius: 9px;
color: #a99cff;
border-radius: var(--r);
color: var(--a-mid);
background: var(--accent-soft);
}
.security-card-icon.twofa-icon {
color: #e5b05e;
color: var(--st-queue);
background: rgba(229,176,94,.1);
}
.security-card-icon.passkey-icon {
color: #6d9fe6;
color: var(--a-blue);
background: rgba(109,159,230,.1);
}
.security-card h3 {
margin: 0;
font-size: 13px;
color: #e8eaf0;
color: var(--tx);
}
.security-value {
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
}
.policy-text {
font-size: 11px;
@@ -234,7 +234,7 @@ onMounted(loadStatus)
.security-desc {
margin: 0;
font-size: 10px;
color: #7e8799;
color: var(--tx-3);
line-height: 1.5;
}
.security-detail-list {
@@ -250,14 +250,14 @@ onMounted(loadStatus)
}
.security-detail-label {
font-size: 10px;
color: #8991a1;
color: var(--tx-2);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.security-detail-value {
font-size: 10px;
color: #e8eaf0;
color: var(--tx);
font-weight: 500;
text-align: right;
word-break: break-word;
@@ -281,11 +281,11 @@ code.security-detail-value {
}
.status-bool.enabled {
background: rgba(81,212,154,.1);
color: #51d49a;
color: var(--st-work);
}
.status-bool.disabled {
background: rgba(225,110,117,.08);
color: #e16e75;
color: var(--st-block);
}
.security-status-row {
display: flex;
@@ -293,16 +293,16 @@ code.security-detail-value {
gap: 6px;
}
.check-icon {
color: #51d49a;
color: var(--st-work);
}
.x-icon {
color: #e16e75;
color: var(--st-block);
}
.enabled-text {
color: #51d49a;
color: var(--st-work);
}
.disabled-text {
color: #e16e75;
color: var(--st-block);
}
.memory-status {
@@ -311,11 +311,11 @@ code.security-detail-value {
justify-content: center;
gap: 8px;
padding: 48px;
color: #7e8799;
color: var(--tx-3);
font-size: 12px;
}
.memory-status.error {
color: #e16e75;
color: var(--st-block);
}
.spin {
animation: spin 1s linear infinite;
+29 -29
View File
@@ -409,7 +409,7 @@ onMounted(() => {
}
.grad-text {
background: linear-gradient(135deg, #4f7cff, #b557f6);
background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
@@ -418,7 +418,7 @@ onMounted(() => {
.settings-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: #6f6aa0;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
}
@@ -444,7 +444,7 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 10px;
color: #ece9ff;
color: var(--tx);
padding-bottom: 14px;
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
}
@@ -470,7 +470,7 @@ onMounted(() => {
.field label {
font-size: 10px;
font-weight: 600;
color: #a8a3d6;
color: var(--tx-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -490,7 +490,7 @@ onMounted(() => {
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 11px;
background: rgba(10, 9, 24, 0.55);
color: #ece9ff;
color: var(--tx);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
outline: none;
@@ -513,13 +513,13 @@ onMounted(() => {
}
.galaxy-select option {
background: #141130;
color: #ece9ff;
background: var(--space-3);
color: var(--tx);
}
.value-tag {
font-size: 14px;
color: #ece9ff;
color: var(--tx);
font-weight: 500;
}
@@ -538,7 +538,7 @@ onMounted(() => {
text-transform: uppercase;
letter-spacing: 0.05em;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
color: #b8adff;
color: var(--a-mid);
border: 1px solid rgba(124, 108, 255, 0.15);
width: fit-content;
}
@@ -589,14 +589,14 @@ onMounted(() => {
border: none;
border-radius: 8px;
background: transparent;
color: #6f6aa0;
color: var(--tx-3);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pw-toggle:hover {
background: rgba(124, 108, 255, 0.08);
color: #a8a3d6;
color: var(--tx-2);
}
/* ── Messages ──────────────────────────────────── */
@@ -613,13 +613,13 @@ onMounted(() => {
.msg.error {
background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.2);
color: #fda4af;
color: var(--st-block);
}
.msg.success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #86efac;
color: var(--st-work);
}
/* ── Buttons ──────────────────────────────────── */
@@ -630,7 +630,7 @@ onMounted(() => {
padding: 9px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
color: #fff;
font-size: 12.5px;
font-weight: 600;
@@ -665,7 +665,7 @@ onMounted(() => {
font-family: 'Manrope', sans-serif;
background: transparent;
border: 1px solid rgba(150, 140, 255, 0.12);
color: #a8a3d6;
color: var(--tx-2);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
@@ -673,7 +673,7 @@ onMounted(() => {
.btn-ghost:hover {
background: rgba(124, 108, 255, 0.08);
color: #ece9ff;
color: var(--tx);
}
/* ── Admin User Management ───────────────────── */
@@ -711,7 +711,7 @@ onMounted(() => {
place-items: center;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
border: 1px solid rgba(124, 108, 255, 0.15);
color: #b8adff;
color: var(--a-mid);
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
@@ -725,12 +725,12 @@ onMounted(() => {
.user-name {
font-size: 13px;
font-weight: 600;
color: #ece9ff;
color: var(--tx);
}
.user-email {
font-size: 11px;
color: #6f6aa0;
color: var(--tx-3);
display: flex;
align-items: center;
gap: 4px;
@@ -754,31 +754,31 @@ onMounted(() => {
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(124, 108, 255, 0.1);
color: #b8adff;
color: var(--a-mid);
border: 1px solid rgba(124, 108, 255, 0.1);
}
.role-tag.owner {
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
color: #fde68a;
color: var(--st-queue);
border-color: rgba(251, 191, 36, 0.2);
}
.role-tag.admin {
background: rgba(79, 124, 255, 0.12);
color: #93c5fd;
color: var(--a-blue);
border-color: rgba(79, 124, 255, 0.2);
}
.role-tag.viewer {
background: rgba(107, 103, 150, 0.15);
color: #a8a3d6;
color: var(--tx-2);
border-color: rgba(107, 103, 150, 0.2);
}
.user-date {
font-size: 10px;
color: #6f6aa0;
color: var(--tx-3);
}
.loading-pulse {
@@ -806,7 +806,7 @@ onMounted(() => {
align-items: center;
gap: 12px;
padding: 32px;
color: #6f6aa0;
color: var(--tx-3);
}
.empty-state p {
@@ -849,7 +849,7 @@ onMounted(() => {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #ece9ff;
color: var(--tx);
font-family: 'Space Grotesk', sans-serif;
}
@@ -861,7 +861,7 @@ onMounted(() => {
justify-content: center;
border: none;
background: transparent;
color: #6f6aa0;
color: var(--tx-3);
cursor: pointer;
border-radius: 6px;
font-size: 20px;
@@ -870,11 +870,11 @@ onMounted(() => {
.modal-close:hover {
background: rgba(124, 108, 255, 0.08);
color: #ece9ff;
color: var(--tx);
}
.req {
color: #fb7185;
color: var(--st-block);
}
.modal-form {
File diff suppressed because it is too large Load Diff
+45 -45
View File
@@ -111,9 +111,9 @@ function statusClass(state: string): string {
function priorityColor(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high') return '#f87171'
if (lower === 'low') return '#60a5fa'
return '#facc15'
if (lower === 'high') return 'var(--st-block)'
if (lower === 'low') return 'var(--a-blue)'
return 'var(--st-queue)'
}
function priorityLabel(p: string): string {
@@ -638,7 +638,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 10px;
background: transparent;
color: #a8a3d6;
color: var(--tx-2);
font-size: 12px;
font-family: 'Manrope', sans-serif;
cursor: pointer;
@@ -648,7 +648,7 @@ function handleKeydown(e: KeyboardEvent) {
.back-btn:hover {
background: rgba(124, 108, 255, 0.08);
color: #ece9ff;
color: var(--tx);
}
/* ── Loading / Error ────────────────────────── */
@@ -659,14 +659,14 @@ function handleKeydown(e: KeyboardEvent) {
justify-content: center;
gap: 16px;
padding: 80px 20px;
color: #6f6aa0;
color: var(--tx-3);
}
.spinner {
width: 24px;
height: 24px;
border: 2.5px solid rgba(150, 140, 255, 0.15);
border-top-color: #7c6cff;
border-top-color: var(--a-mid);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@@ -675,7 +675,7 @@ function handleKeydown(e: KeyboardEvent) {
width: 16px;
height: 16px;
border: 2px solid rgba(150, 140, 255, 0.15);
border-top-color: #7c6cff;
border-top-color: var(--a-mid);
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
@@ -686,7 +686,7 @@ function handleKeydown(e: KeyboardEvent) {
.error-state p {
font-size: 14px;
margin: 0;
color: #fda4af;
color: var(--st-block);
}
/* ── Detail Header ──────────────────────────── */
@@ -711,15 +711,15 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid transparent;
}
.state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.state-badge.is-backlog { color: var(--st-queue); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.state-badge.is-progress { color: var(--st-work); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.state-badge.is-review { color: var(--st-queue); background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.state-badge.is-blocked { color: var(--st-block); background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.state-badge.is-done { color: var(--st-work); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.meta-chip {
font-size: 10px;
color: #6f6aa0;
color: var(--tx-3);
background: rgba(10, 9, 24, 0.35);
padding: 3px 10px;
border-radius: 6px;
@@ -735,7 +735,7 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 10px;
background: rgba(147,51,234,.08);
border: 1px solid rgba(147,51,234,.18);
color: #c084fc;
color: var(--a-purple);
font-size: 12px;
}
@@ -759,7 +759,7 @@ function handleKeydown(e: KeyboardEvent) {
border: none;
border-bottom: 2px solid transparent;
padding: 4px 0;
color: #ece9ff;
color: var(--tx);
font-family: 'Space Grotesk', sans-serif;
font-size: 28px;
font-weight: 700;
@@ -777,7 +777,7 @@ function handleKeydown(e: KeyboardEvent) {
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: #6f6aa0;
color: var(--tx-3);
}
.meta-row span {
@@ -791,7 +791,7 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 12px;
background: rgba(124, 108, 255, 0.08);
border: 1px solid rgba(124, 108, 255, 0.14);
color: #a8a3d6;
color: var(--tx-2);
font-size: 12px;
}
@@ -808,7 +808,7 @@ function handleKeydown(e: KeyboardEvent) {
align-items: center;
gap: 8px;
margin-bottom: 14px;
color: #a8a3d6;
color: var(--tx-2);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
@@ -826,7 +826,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.10);
border-radius: 12px;
background: rgba(10, 9, 24, 0.45);
color: #ece9ff;
color: var(--tx);
font-size: 14px;
line-height: 1.6;
font-family: 'Manrope', sans-serif;
@@ -897,13 +897,13 @@ function handleKeydown(e: KeyboardEvent) {
.subtask-title {
font-size: 13px;
font-weight: 600;
color: #ece9ff;
color: var(--tx);
}
.subtask-detail {
margin: 4px 0 0;
font-size: 11.5px;
color: #6f6aa0;
color: var(--tx-3);
line-height: 1.4;
}
@@ -919,7 +919,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 6px;
background: rgba(10, 9, 24, 0.5);
color: #a8a3d6;
color: var(--tx-2);
font-size: 10px;
font-family: 'Manrope', sans-serif;
cursor: pointer;
@@ -927,8 +927,8 @@ function handleKeydown(e: KeyboardEvent) {
}
.state-select-mini option {
background: #141130;
color: #ece9ff;
background: var(--space-3);
color: var(--tx);
}
.state-select-mini:disabled {
@@ -941,14 +941,14 @@ function handleKeydown(e: KeyboardEvent) {
align-items: center;
gap: 8px;
padding: 16px;
color: #6f6aa0;
color: var(--tx-3);
font-size: 12px;
}
.empty-section {
padding: 20px;
text-align: center;
color: #6f6aa0;
color: var(--tx-3);
font-size: 12px;
}
@@ -996,7 +996,7 @@ function handleKeydown(e: KeyboardEvent) {
display: grid;
place-items: center;
background: rgba(124, 108, 255, 0.08);
color: #7c6cff;
color: var(--a-mid);
flex-shrink: 0;
}
@@ -1007,13 +1007,13 @@ function handleKeydown(e: KeyboardEvent) {
.activity-msg {
font-size: 12.5px;
color: #a8a3d6;
color: var(--tx-2);
line-height: 1.4;
}
.activity-time {
font-size: 10px;
color: #6f6aa0;
color: var(--tx-3);
margin-top: 3px;
}
@@ -1042,7 +1042,7 @@ function handleKeydown(e: KeyboardEvent) {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #a8a3d6;
color: var(--tx-2);
margin-bottom: 14px;
}
@@ -1060,7 +1060,7 @@ function handleKeydown(e: KeyboardEvent) {
.sidebar-field span {
font-size: 10px;
font-weight: 600;
color: #6f6aa0;
color: var(--tx-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -1079,13 +1079,13 @@ function handleKeydown(e: KeyboardEvent) {
}
.info-list dt {
color: #6f6aa0;
color: var(--tx-3);
font-size: 11px;
}
.info-list dd {
margin: 0;
color: #a8a3d6;
color: var(--tx-2);
font-size: 11.5px;
text-align: right;
}
@@ -1104,7 +1104,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 10px;
background: rgba(10, 9, 24, 0.55);
color: #ece9ff;
color: var(--tx);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
outline: none;
@@ -1127,8 +1127,8 @@ function handleKeydown(e: KeyboardEvent) {
}
.galaxy-select option {
background: #141130;
color: #ece9ff;
background: var(--space-3);
color: var(--tx);
}
.galaxy-select:disabled {
@@ -1143,7 +1143,7 @@ function handleKeydown(e: KeyboardEvent) {
padding: 9px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
color: #fff;
font-size: 12.5px;
font-weight: 600;
@@ -1169,7 +1169,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 8px;
background: transparent;
color: #a8a3d6;
color: var(--tx-2);
font-size: 11px;
font-family: 'Manrope', sans-serif;
cursor: pointer;
@@ -1186,13 +1186,13 @@ function handleKeydown(e: KeyboardEvent) {
border: none;
border-radius: 7px;
background: rgba(124, 108, 255, 0.06);
color: #a8a3d6;
color: var(--tx-2);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: #ece9ff; }
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: #fda4af; }
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: var(--tx); }
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: var(--st-block); }
.btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
.msg {
@@ -1202,8 +1202,8 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 8px;
}
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: #fda4af; }
.msg.success { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.15); color: #86efac; display: flex; align-items: center; gap: 6px; }
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: var(--st-block); }
.msg.success { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.15); color: var(--st-work); display: flex; align-items: center; gap: 6px; }
/* ── Responsive ──────────────────────────────── */
@media (max-width: 860px) {
+12 -12
View File
@@ -23,7 +23,7 @@ const agents: AgentCardData[] = [
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6',
color: '#7c6cff',
icon: 'bot',
hero: true,
},
@@ -33,7 +33,7 @@ const agents: AgentCardData[] = [
role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['coding', 'development', 'builds'],
color: '#3b82f6',
color: '#4f7cff',
icon: 'code',
},
{
@@ -42,7 +42,7 @@ const agents: AgentCardData[] = [
role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['Quality Assurance', 'Security', 'Code Review'],
color: '#a855f7',
color: '#b557f6',
icon: 'shield',
},
{
@@ -51,7 +51,7 @@ const agents: AgentCardData[] = [
role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#22c55e',
color: '#3ddc97',
icon: 'search',
},
{
@@ -60,7 +60,7 @@ const agents: AgentCardData[] = [
role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Deployment', 'VPS'],
color: '#eab308',
color: '#fbbf24',
icon: 'terminal',
},
]
@@ -133,7 +133,7 @@ function goToAgent(id: string) {
.quote-text {
font-style: italic;
font-size: 12px;
color: #9ea5b3;
color: var(--tx-2);
line-height: 1.5;
}
@@ -144,17 +144,17 @@ function goToAgent(id: string) {
.team-title {
font-size: 26px;
font-weight: 600;
color: #e8eaf0;
color: var(--tx);
margin: 0 0 6px;
}
.team-subtitle {
font-size: 12px;
color: #7e8799;
color: var(--tx-3);
margin: 0 0 4px;
}
.team-description {
font-size: 10.5px;
color: #6b7385;
color: var(--tx-3);
margin: 0;
max-width: 560px;
margin-left: auto;
@@ -182,7 +182,7 @@ function goToAgent(id: string) {
align-items: center;
gap: 8px;
font-size: 10px;
color: #7e8799;
color: var(--tx-3);
}
.legend-dot {
width: 8px;
@@ -191,11 +191,11 @@ function goToAgent(id: string) {
flex-shrink: 0;
}
.active-pulse {
background: #51d49a;
background: var(--st-work);
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
}
.idle-pulse {
background: #3a3f4b;
background: var(--line-2);
}
.pulse-dot {
background: white;
+3 -2
View File
@@ -1,6 +1,6 @@
# Runtime und Routing
> Letzte Aktualisierung: 2026-06-16
> Letzte Aktualisierung: 2026-07-09
## Aktive Modelle (7 von 8 konfiguriert)
@@ -23,6 +23,7 @@
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
- API kommuniziert über Docker-DNS via `http://openclaw-gateway-bao:18789` im gemeinsamen `openclaw_default`-Netzwerk.
- Der frühere Pfad `host.docker.internal:18789` ist auf dem VPS nicht erreichbar und wurde entfernt.
- **Achtung:** `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` muss durch ApiKey-Auth ersetzt werden (siehe [Architektur-Review](../docs/architecture-board-first-orchestration.md#61-kritisch--allowanonymous-auf-board-endpunkten))
- Vollständige Architektur- und Sicherheitsanalyse: [architecture-board-first-orchestration.md](../docs/architecture-board-first-orchestration.md)