Compare commits

...

6 Commits

Author SHA1 Message Date
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
51 changed files with 2224 additions and 3075 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=*** BOOTSTRAP_OWNER_EMAIL=***
# ── OpenClaw Integration ──────────────────────────────── # ── OpenClaw Integration ────────────────────────────────
# Base URL of the OpenClaw gateway (host.docker.internal from inside container) # Internal Docker-DNS URL of the OpenClaw gateway
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=*** OPENCLAW_GATEWAY_TOKEN=***
OPENCLAW_GATEWAY_PASSWORD=*** OPENCLAW_GATEWAY_PASSWORD=***
+10 -4
View File
@@ -60,7 +60,7 @@ JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de 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_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD= OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=${VERSION} NEXUS_VERSION=${VERSION}
@@ -140,9 +140,15 @@ echo "Checking live health"
retry=0 retry=0
while [ "$retry" -lt 6 ]; do while [ "$retry" -lt 6 ]; do
retry=$((retry + 1)) retry=$((retry + 1))
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
echo "Health check passed" case "$health_body" in
break '{"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 fi
if [ "$retry" -eq 6 ]; then if [ "$retry" -eq 6 ]; then
echo "Health check failed" >&2 echo "Health check failed" >&2
+50 -23
View File
@@ -112,9 +112,11 @@ jobs:
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de 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_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD= OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
NEXUS_GIT_SHA=$(git rev-parse HEAD)
EOF EOF
chmod 600 "${ENV_TMPFILE}" chmod 600 "${ENV_TMPFILE}"
@@ -127,18 +129,38 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm \ git archive --format=tar HEAD | docker run --rm -i \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \ -v "${DEPLOY_PATH}:/dest" \
alpine:latest \ alpine:latest \
sh -c " sh -c '
cd /src && \ set -eu
find . -mindepth 1 -maxdepth 1 \ dest_owner="$(stat -c "%u:%g" /dest)"
! -name .git \ mkdir -p /src-snapshot
-exec cp -r {} /dest/ \; && \ tar -xf - -C /src-snapshot
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest 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}" echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
@@ -151,16 +173,18 @@ jobs:
docker run --rm \ docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \ -w /workspace/nexus \
-i \
docker:cli \ docker:cli \
sh -c " sh -c '
set -e set -eu
echo '🔙 Rolling back to ${{ inputs.target_tag }}' umask 077
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache cat > /tmp/nexus-rollback-env
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate 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" echo "✅ Rollback redeploy completed"
@@ -186,11 +210,14 @@ jobs:
WAIT=1 WAIT=1
while [ $RETRY -lt $MAX ]; do while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1)) RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
echo "" case "$HEALTH_BODY" in
echo "✅ Health check passed (attempt $RETRY/$MAX)" '{"status":"Healthy"'*)
exit 0 echo "✅ Health check passed (attempt $RETRY/$MAX)"
fi exit 0
;;
esac
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..." echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT sleep $WAIT
NEXT=$((WAIT + RETRY)) NEXT=$((WAIT + RETRY))
+94 -15
View File
@@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests
var recoveryService = new StaleTaskRecoveryService( var recoveryService = new StaleTaskRecoveryService(
taskRepository, taskRepository,
activityRepository, activityRepository,
liveUpdateService); liveUpdateService,
new FakeNotificationService());
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None); var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
@@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests
} }
[Fact] [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 fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{ {
StaleHours = 4, StalledMinutes = 45,
IntervalMinutes = 30 IntervalMinutes = 10
}), }),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance); NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None); var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount); Assert.Equal(1, fakeRecoveryService.FlagCallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold); Assert.Equal(0, fakeRecoveryService.ResetCallCount);
Assert.Equal(7, resetCount); Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
Assert.Equal(7, flaggedCount);
} }
[Fact] [Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval() public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
{ {
var fakeRecoveryService = new FakeStaleTaskRecoveryService(); var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{ {
StaleHours = 2, StalledMinutes = 40,
IntervalMinutes = 30 IntervalMinutes = 10
}), }),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance); NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
@@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests
await firstCall.Task.WaitAsync(cts.Token); await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None); await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1); Assert.True(fakeRecoveryService.FlagCallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold); Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
} }
[Fact] [Fact]
@@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService 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 TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; 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) public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ {
CallCount++; ResetCallCount++;
LastThreshold = staleThreshold; LastThreshold = staleThreshold;
OnCall?.Invoke(); OnCall?.Invoke();
return Task.FromResult(7); 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> file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{ {
public T CurrentValue { get; private set; } = currentValue; public T CurrentValue { get; private set; } = currentValue;
+2 -1
View File
@@ -440,7 +440,8 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
var staleTaskRecoveryService = new StaleTaskRecoveryService( var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository, taskRepository,
activityRepository, activityRepository,
liveUpdateService); liveUpdateService,
notificationService);
var taskService = new TaskService( var taskService = new TaskService(
taskRepository, taskRepository,
+94 -38
View File
@@ -236,38 +236,60 @@ public class DashboardController(
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct); var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20)); 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(); while (!ct.IsCancellationRequested)
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
{ {
var envelope = await readTask; var completed = await Task.WhenAny(readTask, heartbeatTask);
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") 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( if (envelope.Type == "notifications.snapshot")
envelope, {
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live"))); var snapshot = envelope.Payload as NotificationSnapshotDto
} ?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
else if (await heartbeatTask) if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
{ continue;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live")); 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")] [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> /// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim. /// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly). /// Falls back to empty string (which authorization helpers reject accordingly).
@@ -331,19 +399,7 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")] [HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct) public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{ => Ok(await taskService.GetChildTaskDtosAsync(id, 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);
}
[HttpGet("tasks/{id:guid}")] [HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct) 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, List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0, int ChildTaskCount = 0,
int OpenChildTaskCount = 0, int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false bool HasVisibleDelegation = false,
int DoneChildTaskCount = 0
); );
public sealed record CreateDashboardTaskRequest( public sealed record CreateDashboardTaskRequest(
@@ -183,6 +184,11 @@ public sealed record PostActivityRequest(
string? Type = null string? Type = null
); );
public sealed record RequestChangesRequest(
string Comment,
string? TargetState = null
);
// ── Agent Workflow DTOs ── // ── Agent Workflow DTOs ──
/// <summary> /// <summary>
@@ -2,5 +2,9 @@ namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService 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); Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
} }
+3
View File
@@ -33,9 +33,12 @@ public interface ITaskService
// Task Board // Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default); Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, 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> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, 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<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default); Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
{ {
try try
{ {
var resetCount = await RunRecoveryOnceAsync(stoppingToken); var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
if (resetCount > 0) if (flaggedCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount); logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) 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(); await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>(); 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"; 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 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)); 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( public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository, ITaskRepository taskRepository,
IActivityRepository activityRepository, 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) public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ {
var threshold = DateTimeOffset.UtcNow - staleThreshold; var threshold = DateTimeOffset.UtcNow - staleThreshold;
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct) private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{ {
var allTasks = await taskRepository.GetAllAsync(ct); var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList(); var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct); return TaskService.BuildMasterBoard(allTasks, activity);
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);
} }
private static string BuildActivityMessage( private static string BuildActivityMessage(
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
} }
private static string FormatDuration(TimeSpan duration) 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)); if (duration.TotalHours >= 1)
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt); 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( public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default) Guid parentTaskId, CancellationToken ct = default)
{ => await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync( public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default) Guid taskId, CancellationToken ct = default)
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); 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( private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, 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 all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct); 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 offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>(); var inProgress = new List<DashboardTaskDto>();
@@ -431,7 +444,10 @@ public sealed class TaskService(
foreach (var task in all) 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()) switch (task.State.ToLowerInvariant())
{ {
case "backlog": offen.Add(dto); break; 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); 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) public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{ {
var normalizedHours = Math.Max(1, staleHours); var normalizedHours = Math.Max(1, staleHours);
@@ -507,28 +595,46 @@ public sealed class TaskService(
.ToList(); .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) public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{ {
var all = await activityRepo.GetRecentAsync(100, ct); var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList(); 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) var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.UpdatedAt)
.ToList(); .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 openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks); var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with return dto with
{ {
ChildTasks = childDtos, ChildTasks = childDtos,
ChildTaskCount = childDtos.Count, ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount, 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 "RefreshTokenExpirationDays": 7
}, },
"TaskRecovery": { "TaskRecovery": {
"StaleHours": 2, "StalledMinutes": 40,
"IntervalMinutes": 30 "IntervalMinutes": 10,
"StaleHours": 2
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+12 -3
View File
@@ -2,6 +2,15 @@ name: nexus
services: services:
postgres: postgres:
image: postgres:17-alpine 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 restart: always
deploy: deploy:
resources: resources:
@@ -50,7 +59,7 @@ services:
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web} Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env} 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. # 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__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-} Admin__ResetToken: ${Admin__ResetToken:-}
@@ -59,7 +68,7 @@ services:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
postgres: postgres:
condition: service_started condition: service_healthy
restart: true restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"] 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" - "127.0.0.1:18880:80"
depends_on: depends_on:
api: api:
condition: service_started condition: service_healthy
restart: true restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] 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> │ │ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
└──────────────┬──────────────────────────────┬────────────────────┘ └──────────────┬──────────────────────────────┬────────────────────┘
host.docker.internal:18789 │ openclaw-gateway-bao:18789 │
│ (Gateway loopback/lan) │ (internes Docker-DNS)
▼ │ ▼ │
┌──────────────────────────────┐ │ ┌──────────────────────────────┐ │
│ OpenClaw Gateway Container │ │ │ 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 POST /api/v1/operations/snapshot
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync() → DashboardService → OpenClawGatewayClient.InvokeToolAsync()
→ POST http://host.docker.internal:18789/tools/invoke → POST http://openclaw-gateway-bao:18789/tools/invoke
Authorization: Bearer <Gateway-Password> Authorization: Bearer <Gateway-Password>
``` ```
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
### 5.2 Docker-Netzwerk & Gateway-Bind ### 5.2 Docker-Netzwerk & Gateway-Bind
**Aktuelles Problem:** **Aktueller Stand (2026-07-09):**
``` ```
compose.yaml: compose.yaml:
api: api:
@@ -228,32 +228,27 @@ compose.yaml:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
networks: networks:
- nexus - nexus
- openclaw_default ← API-Container ist im Gateway-Netzwerk - openclaw_default
Gateway-Konfiguration: 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:** **Ergebnis:**
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt - Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback - Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt - Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):** Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
```json5
// openclaw.json
{
gateway: {
bind: "lan" // war "loopback"
}
}
```
Alternativ: API-Container über Gateway-Container-Namen ansprechen: Produktive Einstellung:
```yaml ```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 ### 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 ```yaml
api: api:
extra_hosts:
- host.docker.internal:host-gateway
environment: 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__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
networks:
- nexus
- openclaw_default
``` ```
The API container: The API container:
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host - Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
- Has `extra_hosts` configured for `host.docker.internal` - Does not depend on a published host port for the Gateway
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD` - 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 | | Scenario | Works? | Why |
|----------|--------|-----| |----------|--------|-----|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly | | `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback | | `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
**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 ```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):** **Test command (from Nexus API container):**
```bash ```bash
curl -s http://host.docker.internal:18789/health curl -s http://openclaw-gateway-bao:18789/
# Expected: 200 if gateway bind is lan/container IP is reachable # Expected: HTTP 200 from inside nexus-api-1
``` ```
### Required .env Vars for Nexus ### Required .env Vars for Nexus
+9
View File
@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 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 Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
+8 -146
View File
@@ -1,152 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' /**
import { Activity } from '@lucide/vue' * App — nur noch Router-Einstieg + Toasts.
import { RouterView, useRoute, useRouter } from 'vue-router' * Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
import { useOperationsStore } from './stores/operations' * und umschließt alle Seiten außer dem Login.
import { useAuthStore } from './stores/auth' */
import AppSidebar from './components/layout/AppSidebar.vue' import { RouterView } from 'vue-router'
import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue' 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> </script>
<template> <template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" /> <RouterView />
<div v-else class="shell"> <ToastContainer />
<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>
</template> </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 { body {
background: hsl(var(--background)); background:
color: hsl(var(--foreground)); 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, font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif; 'Segoe UI', sans-serif;
margin: 0; margin: 0;
@@ -92,6 +95,10 @@ body {
min-height: 100vh; min-height: 100vh;
} }
h1, h2, h3, .font-display {
font-family: 'Space Grotesk', 'Manrope', sans-serif;
}
/* Nexus overrides for existing CSS variables used in dashboard */ /* Nexus overrides for existing CSS variables used in dashboard */
:root { :root {
--nx-bg: #080a0f; --nx-bg: #080a0f;
@@ -126,9 +133,9 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 22px 14px 14px; padding: 22px 14px 14px;
border-right: 1px solid #1a1e27; border-right: 1px solid var(--line);
background: rgba(9, 11, 16, 0.94); background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
backdrop-filter: blur(18px); backdrop-filter: blur(14px);
} }
.brand { .brand {
@@ -143,15 +150,16 @@ body {
height: 35px; height: 35px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid #443d7c; border: none;
border-radius: 10px; border-radius: 11px;
background: linear-gradient(145deg, #241f44, #12121f); background: var(--grad);
color: #b8adff; color: #fff;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13); box-shadow: var(--glow-purple);
} }
.brand strong { .brand strong {
display: block; display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 13px; font-size: 13px;
letter-spacing: 0.14em; letter-spacing: 0.14em;
} }
@@ -178,36 +186,38 @@ body {
gap: 10px; gap: 10px;
border: 0; border: 0;
padding: 9px 10px; padding: 9px 10px;
border-radius: 7px; border-radius: 10px;
background: transparent; background: transparent;
color: #8991a1; color: var(--tx-2);
font-size: 12px; font-size: 12px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.nav button:hover, .nav button:hover {
.nav button.active { color: var(--tx);
color: #ececf5; background: rgba(124, 108, 255, 0.08);
background: var(--nx-accent-soft);
} }
.nav button.active { .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 { .nav button i {
margin-left: auto; margin-left: auto;
padding: 1px 6px; padding: 1px 6px;
border: 1px solid #343947; border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
background: rgba(124, 108, 255, 0.10);
font-size: 9px; font-size: 9px;
font-style: normal; font-style: normal;
} }
.sidebar-bottom { .sidebar-bottom {
margin-top: auto; margin-top: auto;
border-top: 1px solid #1b1f28; border-top: 1px solid var(--line);
padding-top: 10px; padding-top: 10px;
} }
@@ -240,10 +250,12 @@ body {
height: 31px; height: 31px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 50%; border-radius: 10px;
background: #28243f; background: var(--grad-soft);
color: #bcb3ff; border: 1px solid var(--line-2);
color: var(--tx);
font-size: 10px; font-size: 10px;
font-weight: 700;
} }
main { main {
@@ -256,9 +268,9 @@ main {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 30px; padding: 0 30px;
border-bottom: 1px solid #191d25; border-bottom: 1px solid var(--line);
background: rgba(8, 10, 15, 0.68); background: rgba(8, 6, 20, 0.5);
backdrop-filter: blur(16px); backdrop-filter: blur(14px);
} }
.search { .search {
@@ -266,19 +278,20 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 8px 10px; padding: 8px 12px;
border: 1px solid #202530; border: 1px solid var(--line);
border-radius: 7px; border-radius: 11px;
color: #6f7889; background: rgba(124, 108, 255, 0.06);
color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.search kbd { .search kbd {
margin-left: auto; margin-left: auto;
padding: 2px 5px; padding: 2px 5px;
border: 1px solid #2c313d; border: 1px solid var(--line-2);
border-radius: 4px; border-radius: 4px;
color: #606979; color: var(--tx-3);
font-size: 9px; font-size: 9px;
} }
@@ -293,15 +306,20 @@ main {
gap: 6px; gap: 6px;
align-items: center; align-items: center;
font-size: 10px; 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 { .connection.live {
color: var(--nx-green); color: var(--st-work);
} }
.connection.preview { .connection.preview {
color: #e6b75d; color: var(--st-queue);
} }
.ask, .ask,
@@ -309,13 +327,20 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 8px 11px; padding: 8px 13px;
border: 1px solid #37315e; border: none;
border-radius: 7px; border-radius: 10px;
background: #18152a; background: var(--grad);
color: #c4bbff; color: #fff;
font-weight: 600;
box-shadow: var(--glow-purple);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
transition: filter .16s;
}
.ask:hover {
filter: brightness(1.08);
} }
.content { .content {
@@ -331,7 +356,7 @@ main {
.eyebrow, .eyebrow,
.kicker { .kicker {
color: #7065c8; color: var(--a-mid);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.18em; letter-spacing: 0.18em;
@@ -351,9 +376,10 @@ h1 {
} }
.refresh-btn { .refresh-btn {
border-color: var(--nx-line); background: rgba(124, 108, 255, 0.07);
background: var(--nx-panel); border: 1px solid var(--line-2);
color: #a5adba; box-shadow: none;
color: var(--tx-2);
} }
.spin { .spin {
@@ -370,7 +396,7 @@ h1 {
display: none; display: none;
border: 0; border: 0;
background: transparent; background: transparent;
color: #aaa4e7; color: var(--tx-2);
} }
/* ── Keep existing module/layout styles for non-dashboard pages ── */ /* ── Keep existing module/layout styles for non-dashboard pages ── */
@@ -383,9 +409,10 @@ h1 {
.metrics article, .metrics article,
.panel { .panel {
border: 1px solid var(--nx-line); border: 1px solid var(--line);
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96)); background: var(--glass);
border-radius: 9px; border-radius: var(--r);
backdrop-filter: blur(12px);
} }
.metrics article { .metrics article {
@@ -393,7 +420,7 @@ h1 {
} }
.metrics span { .metrics span {
color: #717a8a; color: var(--tx-3);
font-size: 8px; font-size: 8px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@@ -407,12 +434,12 @@ h1 {
} }
.metrics small { .metrics small {
color: #687181; color: var(--tx-3);
font-size: 9px; font-size: 9px;
} }
.metrics small.up { .metrics small.up {
color: #55c995; color: var(--st-work);
} }
.dashboard-grid { .dashboard-grid {
@@ -435,7 +462,7 @@ h1 {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 1px solid #1d222c; border-bottom: 1px solid var(--line);
} }
.panel-head h2 { .panel-head h2 {
@@ -446,7 +473,7 @@ h1 {
.panel-head button { .panel-head button {
border: 0; border: 0;
background: transparent; background: transparent;
color: #8e96a5; color: var(--tx-2);
font-size: 9px; font-size: 9px;
} }
@@ -457,18 +484,18 @@ h1 {
} }
.badge.positive { .badge.positive {
color: var(--nx-green); color: var(--st-work);
background: rgba(81, 212, 154, 0.1); background: rgba(61, 220, 151, 0.12);
} }
.badge.warning { .badge.warning {
color: #e7b660; color: var(--st-queue);
background: rgba(231, 182, 96, 0.1); background: rgba(251, 191, 36, 0.12);
} }
.badge.negative { .badge.negative {
color: #e16e75; color: var(--st-block);
background: rgba(225, 110, 117, 0.1); background: rgba(251, 113, 133, 0.12);
} }
.runtime-row { .runtime-row {
@@ -483,9 +510,10 @@ h1 {
height: 45px; height: 45px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 9px; border-radius: var(--r-sm);
color: #ad9fff; color: var(--a-mid);
background: var(--nx-accent-soft); background: var(--grad-soft);
border: 1px solid var(--line-2);
} }
.runtime-main strong, .runtime-main strong,
@@ -517,7 +545,7 @@ h1 {
width: 3px; width: 3px;
min-height: 5px; min-height: 5px;
border-radius: 3px; border-radius: 3px;
background: linear-gradient(#927fff, #443b7c); background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
} }
.model { .model {
@@ -526,11 +554,11 @@ h1 {
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 12px 2px; padding: 12px 2px;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.model > span:last-child { .model > span:last-child {
color: #687181; color: var(--tx-3);
font-size: 8px; font-size: 8px;
} }
@@ -538,16 +566,16 @@ h1 {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #657083; background: var(--st-idle);
} }
.status-dot.online { .status-dot.online {
background: var(--nx-green); background: var(--st-work);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4); box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
} }
.status-dot.offline { .status-dot.offline {
background: #e16e75; background: var(--st-block);
} }
.project { .project {
@@ -556,7 +584,7 @@ h1 {
align-items: center; align-items: center;
gap: 11px; gap: 11px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.project-letter { .project-letter {
@@ -564,9 +592,9 @@ h1 {
height: 31px; height: 31px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid #353047; border: 1px solid var(--line-2);
border-radius: 7px; border-radius: 7px;
color: #a99cf5; color: var(--a-mid);
font-size: 10px; font-size: 10px;
} }
@@ -581,7 +609,7 @@ h1 {
} }
.project b { .project b {
color: #838c9c; color: var(--tx-2);
font-size: 9px; font-size: 9px;
} }
@@ -590,14 +618,14 @@ h1 {
margin-top: 8px; margin-top: 8px;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: 4px;
background: #242936; background: var(--space-3);
} }
.progress i { .progress i {
display: block; display: block;
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff); background: var(--grad);
} }
.event { .event {
@@ -605,7 +633,7 @@ h1 {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 10px; gap: 10px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.event > span { .event > span {
@@ -613,19 +641,19 @@ h1 {
height: 6px; height: 6px;
margin-top: 4px; margin-top: 4px;
border-radius: 50%; border-radius: 50%;
background: #657083; background: var(--st-idle);
} }
.event > span.runtime { .event > span.runtime {
background: var(--nx-green); background: var(--st-work);
} }
.event > span.deploy { .event > span.deploy {
background: #8b7cf6; background: var(--a-mid);
} }
.event > span.security { .event > span.security {
background: #e5ad52; background: var(--st-queue);
} }
.placeholder { .placeholder {
@@ -639,7 +667,7 @@ h1 {
.placeholder svg { .placeholder svg {
margin-bottom: 18px; margin-bottom: 18px;
color: #8074d8; color: var(--a-mid);
} }
.placeholder h2 { .placeholder h2 {
+25
View File
@@ -53,6 +53,31 @@
--sidebar-w: 248px; --sidebar-w: 248px;
--topbar-h: 62px; --topbar-h: 62px;
--rail-w: 360px; --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);
} }
/* ── Glass card utility ────────────────────────────── */ /* ── Glass card utility ────────────────────────────── */
-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>
@@ -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"> <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 { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents' import { useNotificationStore } from '../../stores/notifications'
import { useTaskStore } from '../../stores/tasks' import { useLiveSyncStore } from '../../stores/liveSync'
import { navigation, icons } from '../../composables/icons' import { railNav, railFooterNav, svg } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons' import { initials } from '../../utils/format'
defineProps<{ defineProps<{
mobileOpen?: boolean mobileOpen?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
close: [] close: []
}>() }>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const agentStore = useAgentStore() const notificationStore = useNotificationStore()
const taskStore = useTaskStore() const liveSync = useLiveSyncStore()
const ownerInitials = computed(() => const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW' auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
) )
function logout() { function isActive(itemRoute?: string): boolean {
auth.logout() if (!itemRoute) return false
router.replace('/login') if (route.path === itemRoute) return true
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
return route.path.startsWith(itemRoute + '/')
} }
/** function navigate(itemRoute?: string) {
* Dynamische Nav-Item-Counts aus den Stores. if (!itemRoute) return
* Überschreibt die hartcodierten `count`-Werte im navigation-Array. emit('close')
*/ router.push(itemRoute)
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
switch (item.label) { async function logout() {
case 'Agenten': await auth.logout()
case 'Hosts · OpenClaw': await router.replace('/login')
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
}
return { const statusLabel = computed(() => {
...item, if (liveSync.connected) return 'Live'
count: dynamicCount ?? item.count, if (liveSync.connecting) return 'Verbinde…'
} return 'Polling'
}),
}))
}) })
</script> </script>
<template> <template>
<aside :class="['sidebar', { open: mobileOpen }]"> <aside :class="['rail', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand --> <!-- Brand -->
<div class="side-top"> <button class="rail-brand" @click="navigate('/dashboard')">
<div class="brand-mark" v-html="icons.command || ''"></div> <span class="brand-mark" v-html="svg('command')"></span>
<div> <span class="rail-label brand-label">NEXUS</span>
<div class="brand-name">NEXUS</div> </button>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation --> <!-- Navigation -->
<nav class="nav"> <nav class="rail-nav v2-scroll">
<NavGroup <button
v-for="(group, idx) in dynamicNavigation" v-for="item in railNav"
:key="idx" :key="item.route"
:label="group.group" :class="['rail-item', { active: isActive(item.route) }]"
:items="group.items" :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> </nav>
<!-- Footer --> <!-- Footer -->
<div class="side-foot"> <div class="rail-foot">
<div class="avatar">{{ ownerInitials }}</div> <div class="rail-item static" :title="statusLabel">
<div class="owner-info"> <span class="rail-icon">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div> <span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div> </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>
</div> </div>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.sidebar { .rail {
width: 248px; position: absolute;
flex: 0 0 248px; inset: 0 auto 0 0;
height: 100vh; width: 68px;
display: flex; display: flex;
flex-direction: column; 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); border-right: 1px solid var(--line);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
padding: 0; overflow: hidden;
position: relative; transition: width .18s ease, box-shadow .18s ease;
z-index: 2; 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; display: flex;
align-items: center; align-items: center;
gap: 11px; gap: 13px;
padding: 18px 18px 16px; padding: 15px;
border: none;
background: transparent;
cursor: pointer;
} }
.brand-mark { .brand-mark {
width: 38px; width: 38px;
height: 38px; height: 38px;
flex: 0 0 38px;
border-radius: 11px; border-radius: 11px;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--grad); background: var(--grad);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
flex: 0 0 auto;
} }
.brand-mark :deep(svg) { .brand-mark :deep(svg) {
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
color: #fff; color: #fff;
} }
.brand-name { .brand-label {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-weight: 700; font-weight: 700;
font-size: 17px; font-size: 16px;
letter-spacing: .14em; letter-spacing: .14em;
line-height: 1; color: var(--tx);
} }
.brand-sub { /* ── Nav ── */
font-size: 10.5px; .rail-nav {
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1; flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
padding: 6px 12px;
overflow-y: auto;
overflow-x: hidden;
} }
.side-foot { .rail-item {
padding: 12px; position: relative;
border-top: 1px solid var(--line);
display: flex; display: flex;
align-items: center; 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; cursor: pointer;
transition: background .15s; transition: background .15s, color .15s;
} }
.side-foot:hover { .rail-item:not(.static):hover {
background: rgba(124,108,255,.06); background: rgba(124, 108, 255, .08);
color: var(--tx);
} }
.sidebar-close { .rail-item.active {
display: none; 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) { .rail-item.static {
.sidebar { cursor: default;
position: fixed; }
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open { .rail-icon {
transform: translateX(0); width: 18px;
} height: 18px;
flex: 0 0 18px;
display: grid;
place-items: center;
opacity: .9;
}
.sidebar-close { .rail-icon :deep(svg) {
display: flex; width: 18px;
align-items: center; height: 18px;
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;
}
.sidebar-close:hover { .rail-item .rail-label {
background: rgba(124,108,255,.1); flex: 1;
color: var(--tx); }
}
.sidebar-close :deep(svg) { /* Ungelesen-Punkt am Icon (collapsed sichtbar) */
width: 18px; .rail-dot {
height: 18px; 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 { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
flex: 0 0 34px;
border-radius: 10px; border-radius: 10px;
background: var(--grad-soft); background: var(--grad-soft);
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 12px;
color: var(--tx); color: var(--tx);
flex-shrink: 0;
} }
.owner-info { .owner-label {
min-width: 0; flex: 1;
display: flex;
flex-direction: column;
} }
.owner-name { .owner-name {
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.owner-role { .owner-role {
font-size: 10px; font-size: 10px;
color: var(--tx-3); color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize; 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> </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>
+21 -35
View File
@@ -25,6 +25,10 @@ export const icons: Record<string, string> = {
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`, arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`, 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"/>`, 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_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 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"/>`, 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[] = [ export const railNav: NavItemDef[] = [
{ { icon: 'grid', label: 'Dashboard', route: '/dashboard' },
group: 'Operations', { icon: 'cpu', label: 'Agenten', route: '/agents' },
items: [ { icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true }, { icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'cpu', label: 'Agenten', route: '/agents' }, { icon: 'doc', label: 'Docs', route: '/docs' },
{ icon: 'list', label: 'Task Board', route: '/tasks' }, { icon: 'calendar', label: 'Kalender', route: '/calendar' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' }, { icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
], { icon: 'alert', label: 'Incidents', route: '/incidents' },
}, { icon: 'shield', label: 'Security', route: '/security' },
{ ]
group: 'Knowledge',
items: [ export const railFooterNav: NavItemDef[] = [
{ icon: 'brain', label: 'Memory', route: '/memory' }, { icon: 'gear', label: 'Einstellungen', route: '/settings' },
{ 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' },
],
},
] ]
+113 -27
View File
@@ -1,49 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* NexusLayout — V2 Dashboard Shell * NexusLayout — gemeinsame Shell für ALLE Seiten
* Flex row, 100vh, overflow hidden. *
* Sidebar (248px) + Main (flex:1, flex-column) * Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
* Mobile: Sidebar als Overlay mit Hamburger-Toggle * 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 { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard' import { useAuthStore } from '../stores/auth'
import { useLiveSyncStore } from '../stores/liveSync'
import { useNotificationStore } from '../stores/notifications'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.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) const mobileMenuOpen = ref(false)
function closeMobileMenu() { function closeMobileMenu() {
mobileMenuOpen.value = false 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> </script>
<template> <template>
<div class="nexus-layout"> <div class="nexus-layout">
<GalaxyBackground /> <GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop --> <div class="rail-slot">
<div <Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
v-if="mobileMenuOpen" </div>
class="mobile-backdrop"
@click="closeMobileMenu" <!-- Mobile: Hamburger + Backdrop -->
></div> <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"> <main class="nexus-main">
<Topbar <div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
</main> </main>
@@ -59,6 +89,15 @@ function closeMobileMenu() {
position: relative; 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 { .nexus-main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -70,19 +109,62 @@ function closeMobileMenu() {
.nexus-content { .nexus-content {
flex: 1; flex: 1;
overflow: hidden;
min-height: 0; 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 { .mobile-backdrop {
display: none; display: none;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.rail-slot {
width: 0;
flex: 0 0 0;
}
.nexus-main { .nexus-main {
width: 100%; 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 { .mobile-backdrop {
display: block; display: block;
position: fixed; position: fixed;
@@ -90,5 +172,9 @@ function closeMobileMenu() {
z-index: 99; z-index: 99;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
.nexus-content.page-scroll {
padding: 60px 16px 48px;
}
} }
</style> </style>
+15 -19
View File
@@ -19,31 +19,27 @@ const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } }, { path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' }, { 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, component: NexusLayout,
children: [ 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' }, { 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, error: null as string | null,
controller: null as AbortController | null, controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null, reconnectTimer: null as ReturnType<typeof setTimeout> | null,
watchdogTimer: null as ReturnType<typeof setInterval> | null,
mode: 'polling' as 'polling' | 'live', mode: 'polling' as 'polling' | 'live',
lastSequence: 0, lastSequence: 0,
reconnectAttempts: 0, reconnectAttempts: 0,
forUser: 'bao',
}), }),
getters: { getters: {
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
if (this.connecting || this.connected) return if (this.connecting || this.connected) return
this.connecting = true this.connecting = true
this.error = null this.error = null
this.forUser = forUser
this.controller = new AbortController() this.controller = new AbortController()
this.startWatchdog()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null }) }, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed 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) { } catch (error) {
if (this.controller?.signal.aborted) return if (!this.controller?.signal.aborted) {
console.warn('[liveSync] stream failed, falling back to polling', error) console.warn('[liveSync] stream failed', error)
this.error = 'Live updates unavailable' this.error = 'Live updates unavailable'
this.connected = false }
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally { } finally {
this.connecting = false 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() { disconnect() {
this.stopWatchdog()
this.controller?.abort() this.controller?.abort()
this.controller = null this.controller = null
this.connected = false this.connected = false
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
scheduleReconnect(forUser = 'bao') { scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return 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.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null this.reconnectTimer = null
+36
View File
@@ -30,6 +30,7 @@ export interface DashboardTaskDto {
lastActivityMessage?: string | null lastActivityMessage?: string | null
lastActivityAt?: string | null lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null childTasks?: DashboardTaskDto[] | null
doneChildTaskCount?: number
childTaskCount?: number childTaskCount?: number
openChildTaskCount?: number openChildTaskCount?: number
hasVisibleDelegation?: boolean 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 ─────────────────────────── */ /* ── API: Create task ─────────────────────────── */
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) { async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
try { 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 ──────── */ /* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) { async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true this.agentOverviewLoading = true
+34 -34
View File
@@ -143,10 +143,10 @@ function formatModifiedAt(dateStr: string): string {
const statusColor = (status: string): string => { const statusColor = (status: string): string => {
switch (status) { switch (status) {
case 'Online': return '#51d49a' case 'Online': return 'var(--st-work)'
case 'Degraded': return '#e5b05e' case 'Degraded': return 'var(--st-queue)'
case 'Offline': return '#e16e75' case 'Offline': return 'var(--st-block)'
default: return '#7e8799' default: return 'var(--tx-3)'
} }
} }
@@ -612,15 +612,15 @@ onUnmounted(() => {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 7px;
background: var(--panel); background: var(--panel);
color: #7e8799; color: var(--tx-3);
font-size: 10.5px; font-size: 10.5px;
cursor: pointer; cursor: pointer;
margin-bottom: 20px; margin-bottom: 20px;
transition: border-color 0.15s, color 0.15s; transition: border-color 0.15s, color 0.15s;
} }
.back-link:hover { .back-link:hover {
border-color: #443d7c; border-color: var(--line-3);
color: #d8dbe3; color: var(--tx);
} }
.status-message { .status-message {
@@ -629,11 +629,11 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 48px; padding: 48px;
color: #7e8799; color: var(--tx-3);
font-size: 12px; font-size: 12px;
} }
.status-message.error { .status-message.error {
color: #e16e75; color: var(--st-block);
} }
.status-message.compact { .status-message.compact {
padding: 20px; padding: 20px;
@@ -660,15 +660,15 @@ onUnmounted(() => {
place-items: center; place-items: center;
border-radius: 12px; border-radius: 12px;
background: rgba(139,124,246,.1); background: rgba(139,124,246,.1);
color: #8b7cf6; color: var(--a-mid);
flex-shrink: 0; flex-shrink: 0;
} }
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; } .agent-avatar.iris { background: rgba(139,124,246,.15); color: var(--a-mid); }
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; } .agent-avatar.programmer { background: rgba(77,140,246,.15); color: var(--a-blue); }
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; } .agent-avatar.architekt { background: rgba(77,168,246,.15); color: var(--a-blue); }
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; } .agent-avatar.reviewer { background: rgba(246,168,77,.15); color: var(--st-queue); }
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; } .agent-avatar.researcher { background: rgba(139,77,246,.15); color: var(--a-purple); }
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; } .agent-avatar.executor { background: rgba(77,246,212,.15); color: var(--st-think); }
.agent-header-info { .agent-header-info {
flex: 1; flex: 1;
@@ -678,7 +678,7 @@ onUnmounted(() => {
font-size: 8.5px; font-size: 8.5px;
font-weight: 700; font-weight: 700;
letter-spacing: .12em; letter-spacing: .12em;
color: var(--accent, #7b6ef2); color: var(--a-mid);
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 2px; margin-bottom: 2px;
} }
@@ -686,7 +686,7 @@ onUnmounted(() => {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
} }
.agent-status-row { .agent-status-row {
display: flex; display: flex;
@@ -702,18 +702,18 @@ onUnmounted(() => {
} }
.status-label { .status-label {
font-size: 11px; font-size: 11px;
color: #7e8799; color: var(--tx-3);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; 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-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 { .thinking-section {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
margin-bottom: 16px; margin-bottom: 16px;
overflow: hidden; overflow: hidden;
@@ -731,11 +731,11 @@ onUnmounted(() => {
font-size: 8.5px; font-size: 8.5px;
font-weight: 700; font-weight: 700;
letter-spacing: .12em; letter-spacing: .12em;
color: var(--accent, #7b6ef2); color: var(--a-mid);
} }
.section-head h2 { .section-head h2 {
margin: 2px 0 0; margin: 2px 0 0;
color: #e8eaf0; color: var(--tx);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
display: inline-flex; display: inline-flex;
@@ -744,7 +744,7 @@ onUnmounted(() => {
} }
.section-note { .section-note {
margin: 6px 0 0; margin: 6px 0 0;
color: #6f788b; color: var(--tx-3);
font-size: 10px; font-size: 10px;
line-height: 1.45; line-height: 1.45;
max-width: 560px; max-width: 560px;
@@ -753,10 +753,10 @@ onUnmounted(() => {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 999px; border-radius: 999px;
background: #6b7385; background: var(--tx-3);
} }
.live-dot.on { .live-dot.on {
background: #51d49a; background: var(--st-work);
} }
.icon-button { .icon-button {
width: 30px; width: 30px;
@@ -764,7 +764,7 @@ onUnmounted(() => {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 7px;
background: rgba(255,255,255,.03); background: rgba(255,255,255,.03);
color: #9ba3b5; color: var(--tx-2);
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
@@ -790,13 +790,13 @@ onUnmounted(() => {
.summary-card small { .summary-card small {
display: block; display: block;
margin-top: 7px; margin-top: 7px;
color: #6f788b; color: var(--tx-3);
font-size: 9.5px; font-size: 9.5px;
line-height: 1.4; line-height: 1.4;
} }
.summary-row span { .summary-row span {
display: block; display: block;
color: #6f788b; color: var(--tx-3);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -804,7 +804,7 @@ onUnmounted(() => {
} }
.summary-row p { .summary-row p {
margin: 0; margin: 0;
color: #cbd0dc; color: var(--tx-2);
font-size: 11px; font-size: 11px;
line-height: 1.5; line-height: 1.5;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -821,20 +821,20 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
color: #6f788b; color: var(--tx-3);
font-size: 10px; font-size: 10px;
} }
.type-pill { .type-pill {
padding: 2px 6px; padding: 2px 6px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(123,110,242,.24); border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff; color: var(--a-mid);
background: rgba(123,110,242,.08); background: rgba(123,110,242,.08);
font-size: 9px; font-size: 9px;
} }
.thinking-item p { .thinking-item p {
margin: 0; margin: 0;
color: #cbd0dc; color: var(--tx-2);
font-size: 11px; font-size: 11px;
line-height: 1.55; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
+35 -35
View File
@@ -41,7 +41,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Chief of Staff', role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.', description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'], tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6', color: '#7c6cff',
icon: 'bot', icon: 'bot',
}, },
{ {
@@ -50,7 +50,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Lead Developer', role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.', description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['Coding', 'Development', 'Builds'], tags: ['Coding', 'Development', 'Builds'],
color: '#4d8cf6', color: '#4f7cff',
icon: 'code', icon: 'code',
}, },
{ {
@@ -59,7 +59,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Infrastructure Engineer', role: 'Infrastructure Engineer',
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.', description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
tags: ['Infrastructure', 'Deployment', 'Docker'], tags: ['Infrastructure', 'Deployment', 'Docker'],
color: '#4da8f6', color: '#4f7cff',
icon: 'server', icon: 'server',
}, },
{ {
@@ -68,7 +68,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Code QA', role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.', description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['QA', 'Security', 'Code Review'], tags: ['QA', 'Security', 'Code Review'],
color: '#f6a84d', color: '#fbbf24',
icon: 'shield', icon: 'shield',
}, },
{ {
@@ -77,7 +77,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Research Analyst', role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.', description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'], tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#8b4df6', color: '#b557f6',
icon: 'search', icon: 'search',
}, },
{ {
@@ -86,7 +86,7 @@ const fallbackAgents: AgentCard[] = [
role: 'Host Executor', role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.', description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Docker', 'VPS'], tags: ['Execution', 'Docker', 'VPS'],
color: '#4df6d4', color: '#34d6f5',
icon: 'terminal', icon: 'terminal',
}, },
] ]
@@ -161,7 +161,7 @@ function enrichAgent(item: any): AgentCard {
role: item.role || fallback?.role || 'Agent', role: item.role || fallback?.role || 'Agent',
description: item.description || fallback?.description || 'OpenClaw agent', description: item.description || fallback?.description || 'OpenClaw agent',
tags: item.tags?.length ? item.tags : fallback?.tags ?? [], tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
color: fallback?.color ?? '#7e8799', color: fallback?.color ?? '#6f6aa0',
icon: fallback?.icon ?? 'bot', icon: fallback?.icon ?? 'bot',
model: item.model, model: item.model,
statusLabel: item.statusLabel, statusLabel: item.statusLabel,
@@ -327,19 +327,19 @@ onMounted(loadMissionControl)
place-items: center; place-items: center;
border-radius: 11px; border-radius: 11px;
background: rgba(139, 124, 246, 0.1); background: rgba(139, 124, 246, 0.1);
color: #8b7cf6; color: var(--a-mid);
flex-shrink: 0; flex-shrink: 0;
} }
.header-text h1 { .header-text h1 {
margin: 0 0 2px; margin: 0 0 2px;
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
} }
.header-subtitle { .header-subtitle {
margin: 0; margin: 0;
font-size: 11px; font-size: 11px;
color: #7e8799; color: var(--tx-3);
} }
.gateway-chip { .gateway-chip {
margin-left: auto; margin-left: auto;
@@ -349,24 +349,24 @@ onMounted(loadMissionControl)
padding: 6px 9px; padding: 6px 9px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 7px;
color: #9ba3b5; color: var(--tx-2);
font-size: 10px; font-size: 10px;
} }
.gateway-chip.ok { .gateway-chip.ok {
color: #51d49a; color: var(--st-work);
border-color: rgba(81, 212, 154, .25); border-color: rgba(81, 212, 154, .25);
} }
.gateway-chip.warn { .gateway-chip.warn {
color: #e5b05e; color: var(--st-queue);
border-color: rgba(229, 176, 94, .28); border-color: rgba(229, 176, 94, .28);
} }
.gateway-chip.error { .gateway-chip.error {
color: #f29b9b; color: var(--st-block);
border-color: rgba(242, 155, 155, .3); border-color: rgba(242, 155, 155, .3);
} }
.load-error { .load-error {
margin-bottom: 14px; margin-bottom: 14px;
color: #e5b05e; color: var(--st-queue);
font-size: 11px; font-size: 11px;
} }
.gateway-warning, .gateway-warning,
@@ -376,19 +376,19 @@ onMounted(loadMissionControl)
border-radius: 11px; border-radius: 11px;
border: 1px solid rgba(229, 176, 94, .24); border: 1px solid rgba(229, 176, 94, .24);
background: rgba(229, 176, 94, .08); background: rgba(229, 176, 94, .08);
color: #f1d7aa; color: var(--st-queue);
font-size: 11px; font-size: 11px;
line-height: 1.5; line-height: 1.5;
} }
.empty-state { .empty-state {
border-color: var(--line); border-color: var(--line);
background: rgba(255,255,255,.03); background: rgba(255,255,255,.03);
color: #aab2c3; color: var(--tx-2);
} }
.empty-state h3 { .empty-state h3 {
margin: 0 0 6px; margin: 0 0 6px;
font-size: 14px; font-size: 14px;
color: #e8eaf0; color: var(--tx);
} }
.empty-state p { .empty-state p {
margin: 0; margin: 0;
@@ -451,7 +451,7 @@ onMounted(loadMissionControl)
height: 36px; height: 36px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 9px; border-radius: var(--r);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -464,19 +464,19 @@ onMounted(loadMissionControl)
margin: 0 0 1px; margin: 0 0 1px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
} }
.card-role { .card-role {
display: block; display: block;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.card-desc { .card-desc {
font-size: 10.5px; font-size: 10.5px;
color: #7e8799; color: var(--tx-3);
line-height: 1.5; line-height: 1.5;
margin: 0 0 10px; margin: 0 0 10px;
flex: 1; flex: 1;
@@ -486,7 +486,7 @@ onMounted(loadMissionControl)
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-bottom: 8px; margin-bottom: 8px;
color: #8a92a5; color: var(--tx-2);
font-size: 9.5px; font-size: 9.5px;
min-width: 0; min-width: 0;
} }
@@ -494,19 +494,19 @@ onMounted(loadMissionControl)
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 999px; border-radius: 999px;
background: #6b7385; background: var(--tx-3);
flex-shrink: 0; flex-shrink: 0;
} }
.runtime-dot.on { .runtime-dot.on {
background: #51d49a; background: var(--st-work);
} }
.runtime-dot.connected { background: #51d49a; } .runtime-dot.connected { background: var(--st-work); }
.runtime-dot.thinking { background: #79aaff; } .runtime-dot.thinking { background: var(--a-blue); }
.runtime-dot.blocked { background: #f87171; } .runtime-dot.blocked { background: var(--st-block); }
.runtime-dot.stale { background: #f59e0b; } .runtime-dot.stale { background: var(--st-queue); }
.runtime-dot.error { background: #f29b9b; } .runtime-dot.error { background: var(--st-block); }
.runtime-dot.unsupported { background: #e5b05e; } .runtime-dot.unsupported { background: var(--st-queue); }
.runtime-dot.ready { background: #6b7385; } .runtime-dot.ready { background: var(--tx-3); }
.runtime-model { .runtime-model {
margin-left: auto; margin-left: auto;
max-width: 46%; max-width: 46%;
@@ -514,12 +514,12 @@ onMounted(loadMissionControl)
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: #6f788b; color: var(--tx-3);
} }
.runtime-detail { .runtime-detail {
margin: 0 0 10px; margin: 0 0 10px;
min-height: 28px; min-height: 28px;
color: #7e8799; color: var(--tx-3);
font-size: 10px; font-size: 10px;
line-height: 1.4; line-height: 1.4;
} }
@@ -561,7 +561,7 @@ onMounted(loadMissionControl)
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
color: #6b7385; color: var(--tx-3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
transition: color 0.15s; transition: color 0.15s;
+20 -20
View File
@@ -219,7 +219,7 @@ onMounted(() => {
} }
.calendar-panel { .calendar-panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 16px; padding: 16px;
} }
@@ -235,7 +235,7 @@ onMounted(() => {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
} }
.calendar-status { .calendar-status {
display: flex; display: flex;
@@ -243,11 +243,11 @@ onMounted(() => {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
color: #7e8799; color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.calendar-status.error { .calendar-status.error {
color: #e16e75; color: var(--st-block);
} }
/* Upcoming jobs */ /* Upcoming jobs */
@@ -268,25 +268,25 @@ onMounted(() => {
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 6px; border-radius: 6px;
color: #a99cf5; color: var(--a-mid);
background: rgba(139,124,246,.1); background: rgba(139,124,246,.1);
} }
.upcoming-item-info strong { .upcoming-item-info strong {
display: block; display: block;
font-size: 11px; font-size: 11px;
margin-bottom: 2px; margin-bottom: 2px;
color: #e8eaf0; color: var(--tx);
} }
.upcoming-item-schedule { .upcoming-item-schedule {
display: block; display: block;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
margin-bottom: 2px; margin-bottom: 2px;
} }
.upcoming-item-next { .upcoming-item-next {
display: block; display: block;
font-size: 9px; font-size: 9px;
color: #a99cf5; color: var(--a-mid);
font-weight: 600; font-weight: 600;
} }
@@ -310,19 +310,19 @@ onMounted(() => {
border-radius: 6px; border-radius: 6px;
} }
.job-item-icon.status-completed { .job-item-icon.status-completed {
color: #27ae60; color: var(--st-work);
background: rgba(39, 174, 96, 0.12); background: rgba(39, 174, 96, 0.12);
} }
.job-item-icon.status-running { .job-item-icon.status-running {
color: #3498db; color: var(--a-blue);
background: rgba(52, 152, 219, 0.12); background: rgba(52, 152, 219, 0.12);
} }
.job-item-icon.status-failed { .job-item-icon.status-failed {
color: #e74c3c; color: var(--st-block);
background: rgba(231, 76, 60, 0.12); background: rgba(231, 76, 60, 0.12);
} }
.job-item-icon.status-idle { .job-item-icon.status-idle {
color: #7e8799; color: var(--tx-3);
background: rgba(126, 135, 153, 0.12); background: rgba(126, 135, 153, 0.12);
} }
.job-item-info { .job-item-info {
@@ -332,19 +332,19 @@ onMounted(() => {
display: block; display: block;
font-size: 11px; font-size: 11px;
margin-bottom: 2px; margin-bottom: 2px;
color: #e8eaf0; color: var(--tx);
} }
.job-item-id { .job-item-id {
display: block; display: block;
font-size: 9px; font-size: 9px;
color: #6b7385; color: var(--tx-3);
margin-bottom: 2px; margin-bottom: 2px;
font-family: monospace; font-family: monospace;
} }
.job-item-schedule { .job-item-schedule {
display: block; display: block;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
} }
.job-item-meta { .job-item-meta {
display: flex; display: flex;
@@ -355,7 +355,7 @@ onMounted(() => {
} }
.job-item-meta small { .job-item-meta small {
font-size: 8px; font-size: 8px;
color: #6b7385; color: var(--tx-3);
text-align: right; text-align: right;
} }
.job-status-badge { .job-status-badge {
@@ -368,19 +368,19 @@ onMounted(() => {
} }
.job-status-badge.status-completed { .job-status-badge.status-completed {
background: rgba(39, 174, 96, 0.15); background: rgba(39, 174, 96, 0.15);
color: #27ae60; color: var(--st-work);
} }
.job-status-badge.status-running { .job-status-badge.status-running {
background: rgba(52, 152, 219, 0.15); background: rgba(52, 152, 219, 0.15);
color: #3498db; color: var(--a-blue);
} }
.job-status-badge.status-failed { .job-status-badge.status-failed {
background: rgba(231, 76, 60, 0.15); background: rgba(231, 76, 60, 0.15);
color: #e74c3c; color: var(--st-block);
} }
.job-status-badge.status-idle { .job-status-badge.status-idle {
background: rgba(126, 135, 153, 0.15); background: rgba(126, 135, 153, 0.15);
color: #7e8799; color: var(--tx-3);
} }
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat' import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard' import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import { useLiveSyncStore } from '../../stores/liveSync'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue' import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const liveSyncStore = useLiveSyncStore()
const router = useRouter() const router = useRouter()
const { const {
@@ -70,7 +68,6 @@ onMounted(() => {
dashboardStore.startPolling() dashboardStore.startPolling()
taskStore.startPolling() taskStore.startPolling()
taskStore.startBoardPolling() taskStore.startBoardPolling()
liveSyncStore.connect()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -79,7 +76,6 @@ onUnmounted(() => {
dashboardStore.stopPolling() dashboardStore.stopPolling()
taskStore.stopPolling() taskStore.stopPolling()
taskStore.stopBoardPolling() taskStore.stopBoardPolling()
liveSyncStore.disconnect()
}) })
</script> </script>
+32 -32
View File
@@ -203,14 +203,14 @@ onMounted(loadDocs)
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
color: #6f7889; color: var(--tx-3);
} }
.docs-search-bar input { .docs-search-bar input {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
} }
@@ -222,13 +222,13 @@ onMounted(loadDocs)
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
color: #8991a1; color: var(--tx-2);
} }
.docs-filter-group select { .docs-filter-group select {
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
@@ -241,7 +241,7 @@ onMounted(loadDocs)
} }
.memory-sidebar { .memory-sidebar {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 8px; padding: 8px;
max-height: 640px; max-height: 640px;
@@ -250,7 +250,7 @@ onMounted(loadDocs)
.memory-list-header { .memory-list-header {
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
color: #7065c8; color: var(--a-mid);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.14em;
padding: 10px 8px 6px; padding: 10px 8px 6px;
@@ -264,7 +264,7 @@ onMounted(loadDocs)
border: none; border: none;
border-radius: 7px; border-radius: 7px;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
@@ -280,7 +280,7 @@ onMounted(loadDocs)
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 6px; border-radius: 6px;
color: #a99cf5; color: var(--a-mid);
background: rgba(139,124,246,.1); background: rgba(139,124,246,.1);
} }
.memory-file-info strong { .memory-file-info strong {
@@ -305,41 +305,41 @@ onMounted(loadDocs)
} }
.doc-category-badge.cat-phases { .doc-category-badge.cat-phases {
background: rgba(139,124,246,.12); background: rgba(139,124,246,.12);
color: #a99cf5; color: var(--a-mid);
} }
.doc-category-badge.cat-skills { .doc-category-badge.cat-skills {
background: rgba(81,212,154,.1); background: rgba(81,212,154,.1);
color: #51d49a; color: var(--st-work);
} }
.doc-category-badge.cat-workspace { .doc-category-badge.cat-workspace {
background: rgba(229,176,94,.1); background: rgba(229,176,94,.1);
color: #e5b05e; color: var(--st-queue);
} }
.doc-category-badge.cat-nexus { .doc-category-badge.cat-nexus {
background: rgba(109,159,230,.1); background: rgba(109,159,230,.1);
color: #6d9fe6; color: var(--a-blue);
} }
.doc-category-badge.cat-nexus-phases { .doc-category-badge.cat-nexus-phases {
background: rgba(225,110,117,.1); background: rgba(225,110,117,.1);
color: #e16e75; color: var(--st-block);
} }
.doc-type-tag { .doc-type-tag {
font-size: 8px; font-size: 8px;
padding: 1px 5px; padding: 1px 5px;
border: 1px solid #343947; border: 1px solid var(--line-2);
border-radius: 4px; border-radius: 4px;
color: #8991a1; color: var(--tx-2);
} }
.memory-file-meta { .memory-file-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
} }
.memory-content { .memory-content {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 24px; padding: 24px;
min-height: 480px; min-height: 480px;
@@ -363,7 +363,7 @@ onMounted(loadDocs)
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
margin-top: 4px; margin-top: 4px;
} }
.memory-back-btn { .memory-back-btn {
@@ -374,13 +374,13 @@ onMounted(loadDocs)
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: #8991a1; color: var(--tx-2);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.memory-back-btn:hover { .memory-back-btn:hover {
color: #e8eaf0; color: var(--tx);
background: var(--accent-soft); background: var(--accent-soft);
} }
.memory-status { .memory-status {
@@ -389,11 +389,11 @@ onMounted(loadDocs)
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
color: #7e8799; color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.memory-status.error { .memory-status.error {
color: #e16e75; color: var(--st-block);
} }
.memory-empty-state { .memory-empty-state {
display: flex; display: flex;
@@ -402,27 +402,27 @@ onMounted(loadDocs)
justify-content: center; justify-content: center;
text-align: center; text-align: center;
min-height: 360px; min-height: 360px;
color: #6b7385; color: var(--tx-3);
} }
.memory-empty-state h3 { .memory-empty-state h3 {
margin: 12px 0 6px; margin: 12px 0 6px;
font-size: 14px; font-size: 14px;
color: #a5adba; color: var(--tx-2);
} }
.memory-empty-state p { .memory-empty-state p {
margin: 0; margin: 0;
font-size: 10px; font-size: 10px;
color: #7e8799; color: var(--tx-3);
} }
.memory-rendered { .memory-rendered {
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #d0d4dd; color: var(--tx-2);
} }
.memory-rendered :deep(h1), .memory-rendered :deep(h1),
.memory-rendered :deep(h2), .memory-rendered :deep(h2),
.memory-rendered :deep(h3) { .memory-rendered :deep(h3) {
color: #e8eaf0; color: var(--tx);
margin: 1.2em 0 0.5em; margin: 1.2em 0 0.5em;
} }
.memory-rendered :deep(h1) { font-size: 1.3rem; } .memory-rendered :deep(h1) { font-size: 1.3rem; }
@@ -438,8 +438,8 @@ onMounted(loadDocs)
.memory-rendered :deep(pre) { .memory-rendered :deep(pre) {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
background: #0d1016; background: var(--space-2);
border: 1px solid var(--nx-line); border: 1px solid var(--line);
overflow-x: auto; overflow-x: auto;
} }
.memory-rendered :deep(pre code) { .memory-rendered :deep(pre code) {
@@ -447,7 +447,7 @@ onMounted(loadDocs)
padding: 0; padding: 0;
} }
.memory-rendered :deep(a) { .memory-rendered :deep(a) {
color: #a99cf5; color: var(--a-mid);
text-decoration: none; text-decoration: none;
} }
.memory-rendered :deep(a:hover) { .memory-rendered :deep(a:hover) {
@@ -461,11 +461,11 @@ onMounted(loadDocs)
} }
.memory-rendered :deep(hr) { .memory-rendered :deep(hr) {
border: none; border: none;
border-top: 1px solid var(--nx-line); border-top: 1px solid var(--line);
margin: 1.2em 0; margin: 1.2em 0;
} }
.memory-rendered :deep(strong) { .memory-rendered :deep(strong) {
color: #e8eaf0; color: var(--tx);
} }
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
+29 -29
View File
@@ -202,7 +202,7 @@ onMounted(loadIncidents)
} }
.incident-sidebar { .incident-sidebar {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 8px; padding: 8px;
max-height: 640px; max-height: 640px;
@@ -211,7 +211,7 @@ onMounted(loadIncidents)
.incident-list-header { .incident-list-header {
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
color: #7065c8; color: var(--a-mid);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.14em;
padding: 10px 8px 6px; padding: 10px 8px 6px;
@@ -225,7 +225,7 @@ onMounted(loadIncidents)
border: none; border: none;
border-radius: 7px; border-radius: 7px;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
@@ -243,19 +243,19 @@ onMounted(loadIncidents)
border-radius: 6px; border-radius: 6px;
} }
.incident-file-icon.sev-critical { .incident-file-icon.sev-critical {
color: #e74c3c; color: var(--st-block);
background: rgba(231, 76, 60, 0.12); background: rgba(231, 76, 60, 0.12);
} }
.incident-file-icon.sev-major { .incident-file-icon.sev-major {
color: #e67e22; color: var(--st-queue);
background: rgba(230, 126, 34, 0.12); background: rgba(230, 126, 34, 0.12);
} }
.incident-file-icon.sev-minor { .incident-file-icon.sev-minor {
color: #f1c40f; color: var(--st-queue);
background: rgba(241, 196, 15, 0.12); background: rgba(241, 196, 15, 0.12);
} }
.incident-file-icon.sev-unknown { .incident-file-icon.sev-unknown {
color: #7e8799; color: var(--tx-3);
background: rgba(126, 135, 153, 0.12); background: rgba(126, 135, 153, 0.12);
} }
.incident-file-info strong { .incident-file-info strong {
@@ -269,7 +269,7 @@ onMounted(loadIncidents)
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
margin-bottom: 4px; margin-bottom: 4px;
} }
.incident-file-excerpt { .incident-file-excerpt {
@@ -278,7 +278,7 @@ onMounted(loadIncidents)
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
font-size: 9px; font-size: 9px;
color: #6b7385; color: var(--tx-3);
line-height: 1.5; line-height: 1.5;
} }
.severity-badge { .severity-badge {
@@ -291,23 +291,23 @@ onMounted(loadIncidents)
} }
.severity-badge.sev-critical { .severity-badge.sev-critical {
background: rgba(231, 76, 60, 0.15); background: rgba(231, 76, 60, 0.15);
color: #e74c3c; color: var(--st-block);
} }
.severity-badge.sev-major { .severity-badge.sev-major {
background: rgba(230, 126, 34, 0.15); background: rgba(230, 126, 34, 0.15);
color: #e67e22; color: var(--st-queue);
} }
.severity-badge.sev-minor { .severity-badge.sev-minor {
background: rgba(241, 196, 15, 0.15); background: rgba(241, 196, 15, 0.15);
color: #f1c40f; color: var(--st-queue);
} }
.severity-badge.sev-unknown { .severity-badge.sev-unknown {
background: rgba(126, 135, 153, 0.15); background: rgba(126, 135, 153, 0.15);
color: #7e8799; color: var(--tx-3);
} }
.incident-content { .incident-content {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 24px; padding: 24px;
min-height: 480px; min-height: 480px;
@@ -335,7 +335,7 @@ onMounted(loadIncidents)
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
} }
.incident-back-btn { .incident-back-btn {
display: flex; display: flex;
@@ -345,13 +345,13 @@ onMounted(loadIncidents)
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: #8991a1; color: var(--tx-2);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.incident-back-btn:hover { .incident-back-btn:hover {
color: #e8eaf0; color: var(--tx);
background: var(--accent-soft); background: var(--accent-soft);
} }
.incident-status { .incident-status {
@@ -360,11 +360,11 @@ onMounted(loadIncidents)
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
color: #7e8799; color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.incident-status.error { .incident-status.error {
color: #e16e75; color: var(--st-block);
} }
.incident-empty-state { .incident-empty-state {
display: flex; display: flex;
@@ -373,27 +373,27 @@ onMounted(loadIncidents)
justify-content: center; justify-content: center;
text-align: center; text-align: center;
min-height: 360px; min-height: 360px;
color: #6b7385; color: var(--tx-3);
} }
.incident-empty-state h3 { .incident-empty-state h3 {
margin: 12px 0 6px; margin: 12px 0 6px;
font-size: 14px; font-size: 14px;
color: #a5adba; color: var(--tx-2);
} }
.incident-empty-state p { .incident-empty-state p {
margin: 0; margin: 0;
font-size: 10px; font-size: 10px;
color: #7e8799; color: var(--tx-3);
} }
.incident-rendered { .incident-rendered {
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #d0d4dd; color: var(--tx-2);
} }
.incident-rendered :deep(h1), .incident-rendered :deep(h1),
.incident-rendered :deep(h2), .incident-rendered :deep(h2),
.incident-rendered :deep(h3) { .incident-rendered :deep(h3) {
color: #e8eaf0; color: var(--tx);
margin: 1.2em 0 0.5em; margin: 1.2em 0 0.5em;
} }
.incident-rendered :deep(h1) { font-size: 1.3rem; } .incident-rendered :deep(h1) { font-size: 1.3rem; }
@@ -409,8 +409,8 @@ onMounted(loadIncidents)
.incident-rendered :deep(pre) { .incident-rendered :deep(pre) {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
background: #0d1016; background: var(--space-2);
border: 1px solid var(--nx-line); border: 1px solid var(--line);
overflow-x: auto; overflow-x: auto;
} }
.incident-rendered :deep(pre code) { .incident-rendered :deep(pre code) {
@@ -418,7 +418,7 @@ onMounted(loadIncidents)
padding: 0; padding: 0;
} }
.incident-rendered :deep(a) { .incident-rendered :deep(a) {
color: #a99cf5; color: var(--a-mid);
text-decoration: none; text-decoration: none;
} }
.incident-rendered :deep(a:hover) { .incident-rendered :deep(a:hover) {
@@ -432,11 +432,11 @@ onMounted(loadIncidents)
} }
.incident-rendered :deep(hr) { .incident-rendered :deep(hr) {
border: none; border: none;
border-top: 1px solid var(--nx-line); border-top: 1px solid var(--line);
margin: 1.2em 0; margin: 1.2em 0;
} }
.incident-rendered :deep(strong) { .incident-rendered :deep(strong) {
color: #e8eaf0; color: var(--tx);
} }
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
+25 -25
View File
@@ -227,22 +227,22 @@ onMounted(loadMemories)
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
margin-bottom: 16px; margin-bottom: 16px;
color: #6f7889; color: var(--tx-3);
} }
.memory-search-bar input { .memory-search-bar input {
flex: 1; flex: 1;
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
} }
.memory-search-bar kbd { .memory-search-bar kbd {
padding: 2px 5px; padding: 2px 5px;
border: 1px solid #2c313d; border: 1px solid var(--line-2);
border-radius: 4px; border-radius: 4px;
color: #606979; color: var(--tx-3);
font-size: 9px; font-size: 9px;
} }
.memory-layout { .memory-layout {
@@ -253,7 +253,7 @@ onMounted(loadMemories)
} }
.memory-sidebar { .memory-sidebar {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 8px; padding: 8px;
max-height: 640px; max-height: 640px;
@@ -262,7 +262,7 @@ onMounted(loadMemories)
.memory-list-header { .memory-list-header {
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
color: #7065c8; color: var(--a-mid);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.14em;
padding: 10px 8px 6px; padding: 10px 8px 6px;
@@ -276,7 +276,7 @@ onMounted(loadMemories)
border: none; border: none;
border-radius: 7px; border-radius: 7px;
background: transparent; background: transparent;
color: #e8eaf0; color: var(--tx);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
@@ -292,7 +292,7 @@ onMounted(loadMemories)
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 6px; border-radius: 6px;
color: #a99cf5; color: var(--a-mid);
background: rgba(139,124,246,.1); background: rgba(139,124,246,.1);
} }
.memory-file-info strong { .memory-file-info strong {
@@ -307,7 +307,7 @@ onMounted(loadMemories)
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
line-height: 1.5; line-height: 1.5;
} }
.memory-file-excerpt { .memory-file-excerpt {
@@ -318,7 +318,7 @@ onMounted(loadMemories)
} }
.memory-content { .memory-content {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 24px; padding: 24px;
min-height: 480px; min-height: 480px;
@@ -342,7 +342,7 @@ onMounted(loadMemories)
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 9px; font-size: 9px;
color: #7e8799; color: var(--tx-3);
} }
.memory-back-btn { .memory-back-btn {
display: flex; display: flex;
@@ -352,13 +352,13 @@ onMounted(loadMemories)
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: #8991a1; color: var(--tx-2);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.memory-back-btn:hover { .memory-back-btn:hover {
color: #e8eaf0; color: var(--tx);
background: var(--accent-soft); background: var(--accent-soft);
} }
.memory-status { .memory-status {
@@ -367,11 +367,11 @@ onMounted(loadMemories)
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
color: #7e8799; color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.memory-status.error { .memory-status.error {
color: #e16e75; color: var(--st-block);
} }
.memory-empty-state { .memory-empty-state {
display: flex; display: flex;
@@ -380,27 +380,27 @@ onMounted(loadMemories)
justify-content: center; justify-content: center;
text-align: center; text-align: center;
min-height: 360px; min-height: 360px;
color: #6b7385; color: var(--tx-3);
} }
.memory-empty-state h3 { .memory-empty-state h3 {
margin: 12px 0 6px; margin: 12px 0 6px;
font-size: 14px; font-size: 14px;
color: #a5adba; color: var(--tx-2);
} }
.memory-empty-state p { .memory-empty-state p {
margin: 0; margin: 0;
font-size: 10px; font-size: 10px;
color: #7e8799; color: var(--tx-3);
} }
.memory-rendered { .memory-rendered {
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #d0d4dd; color: var(--tx-2);
} }
.memory-rendered :deep(h1), .memory-rendered :deep(h1),
.memory-rendered :deep(h2), .memory-rendered :deep(h2),
.memory-rendered :deep(h3) { .memory-rendered :deep(h3) {
color: #e8eaf0; color: var(--tx);
margin: 1.2em 0 0.5em; margin: 1.2em 0 0.5em;
} }
.memory-rendered :deep(h1) { font-size: 1.3rem; } .memory-rendered :deep(h1) { font-size: 1.3rem; }
@@ -416,8 +416,8 @@ onMounted(loadMemories)
.memory-rendered :deep(pre) { .memory-rendered :deep(pre) {
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 8px;
background: #0d1016; background: var(--space-2);
border: 1px solid var(--nx-line); border: 1px solid var(--line);
overflow-x: auto; overflow-x: auto;
} }
.memory-rendered :deep(pre code) { .memory-rendered :deep(pre code) {
@@ -425,7 +425,7 @@ onMounted(loadMemories)
padding: 0; padding: 0;
} }
.memory-rendered :deep(a) { .memory-rendered :deep(a) {
color: #a99cf5; color: var(--a-mid);
text-decoration: none; text-decoration: none;
} }
.memory-rendered :deep(a:hover) { .memory-rendered :deep(a:hover) {
@@ -439,11 +439,11 @@ onMounted(loadMemories)
} }
.memory-rendered :deep(hr) { .memory-rendered :deep(hr) {
border: none; border: none;
border-top: 1px solid var(--nx-line); border-top: 1px solid var(--line);
margin: 1.2em 0; margin: 1.2em 0;
} }
.memory-rendered :deep(strong) { .memory-rendered :deep(strong) {
color: #e8eaf0; color: var(--tx);
} }
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
+14 -18
View File
@@ -2,12 +2,10 @@
import { onMounted, onUnmounted, computed } from 'vue' import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import { useLiveSyncStore } from '../stores/liveSync'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue' import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore() const store = useNotificationStore()
const router = useRouter() const router = useRouter()
const liveSyncStore = useLiveSyncStore()
const sortedNotifications = computed(() => { const sortedNotifications = computed(() => {
return [...store.notifications].sort( return [...store.notifications].sort(
@@ -26,10 +24,10 @@ function typeIcon(type: string): string {
function typeColor(type: string): string { function typeColor(type: string): string {
switch (type) { switch (type) {
case 'task_assigned': return '#4d8cf6' case 'task_assigned': return '#4f7cff'
case 'task_review': return '#f6a84d' case 'task_review': return '#fbbf24'
case 'task_blocked': return '#e16e75' case 'task_blocked': return '#fb7185'
default: return '#7b6ef2' default: return '#7c6cff'
} }
} }
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
onMounted(() => { onMounted(() => {
store.startListPolling() store.startListPolling()
liveSyncStore.connect()
}) })
onUnmounted(() => { onUnmounted(() => {
store.stopListPolling() store.stopListPolling()
liveSyncStore.disconnect()
}) })
</script> </script>
@@ -137,18 +133,18 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330); border: 1px solid var(--line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: var(--nx-text-dim, #6f7889); color: var(--tx-3);
font-size: 10.5px; font-size: 10.5px;
cursor: pointer; cursor: pointer;
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.mark-all-btn:hover { .mark-all-btn:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .08)); background: rgba(124, 108, 255, 0.08);
color: #d8dbe3; color: var(--tx);
} }
/* ── Empty State ── */ /* ── Empty State ── */
@@ -158,7 +154,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 80px 0; padding: 80px 0;
color: var(--nx-text-dim, #6f7889); color: var(--tx-3);
gap: 16px; gap: 16px;
} }
@@ -186,7 +182,7 @@ onUnmounted(() => {
} }
.notification-card:hover { .notification-card:hover {
background: var(--nx-accent-soft, rgba(123, 110, 242, .06)); background: rgba(124, 108, 255, 0.06);
} }
.notification-card.unread { .notification-card.unread {
@@ -215,7 +211,7 @@ onUnmounted(() => {
.card-title { .card-title {
font-size: 12.5px; font-size: 12.5px;
color: #d8dbe3; color: var(--tx);
line-height: 1.4; line-height: 1.4;
} }
@@ -226,7 +222,7 @@ onUnmounted(() => {
.card-message { .card-message {
font-size: 10.5px; font-size: 10.5px;
color: var(--nx-text-dim, #6f7889); color: var(--tx-3);
margin-top: 3px; margin-top: 3px;
line-height: 1.3; line-height: 1.3;
} }
@@ -242,12 +238,12 @@ onUnmounted(() => {
.timestamp { .timestamp {
font-size: 9px; font-size: 9px;
color: var(--nx-text-dim, #6f7889); color: var(--tx-3);
white-space: nowrap; white-space: nowrap;
} }
.arrow { .arrow {
color: var(--nx-text-dim, #6f7889); color: var(--tx-3);
opacity: .5; opacity: .5;
} }
</style> </style>
+15 -15
View File
@@ -221,7 +221,7 @@ onMounted(loadProject)
} }
.project-detail-card { .project-detail-card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
} }
@@ -234,7 +234,7 @@ onMounted(loadProject)
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 10px; border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); background: linear-gradient(135deg, var(--a-mid), var(--accent-secondary));
color: #fff; color: #fff;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
@@ -273,7 +273,7 @@ onMounted(loadProject)
} }
.btn-icon { .btn-icon {
background: var(--surface-raised); background: var(--surface-raised);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
padding: 0.5rem; padding: 0.5rem;
cursor: pointer; cursor: pointer;
@@ -282,8 +282,8 @@ onMounted(loadProject)
transition: all 0.15s; transition: all 0.15s;
} }
.btn-icon:hover { .btn-icon:hover {
background: var(--nx-accent-soft); background: rgba(124, 108, 255, 0.10);
color: var(--nx-accent); color: var(--a-mid);
} }
.btn-icon.btn-danger:hover { .btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
@@ -294,7 +294,7 @@ onMounted(loadProject)
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--surface-raised); background: var(--surface-raised);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
font-size: 1.1rem; font-size: 1.1rem;
color: var(--text-primary); color: var(--text-primary);
@@ -304,7 +304,7 @@ onMounted(loadProject)
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--surface-raised); background: var(--surface-raised);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-primary); color: var(--text-primary);
@@ -321,7 +321,7 @@ onMounted(loadProject)
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
background: var(--nx-accent); background: var(--a-mid);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -335,7 +335,7 @@ onMounted(loadProject)
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
background: var(--surface-raised); background: var(--surface-raised);
color: var(--text-secondary); color: var(--text-secondary);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 6px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
@@ -343,7 +343,7 @@ onMounted(loadProject)
.progress-section { .progress-section {
margin-top: 1.25rem; margin-top: 1.25rem;
padding-top: 1rem; padding-top: 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--line-2);
} }
.progress-header { .progress-header {
display: flex; display: flex;
@@ -361,7 +361,7 @@ onMounted(loadProject)
.progress-bar i { .progress-bar i {
display: block; display: block;
height: 100%; 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; border-radius: 4px;
transition: width 0.3s; transition: width 0.3s;
} }
@@ -380,7 +380,7 @@ onMounted(loadProject)
padding: 1.5rem; padding: 1.5rem;
text-align: center; text-align: center;
background: var(--surface); background: var(--surface);
border: 1px dashed var(--border); border: 1px dashed var(--line-2);
border-radius: 8px; border-radius: 8px;
} }
.task-list { .task-list {
@@ -394,7 +394,7 @@ onMounted(loadProject)
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
} }
.task-icon { .task-icon {
@@ -403,7 +403,7 @@ onMounted(loadProject)
.task-icon.done { color: rgb(34, 197, 94); } .task-icon.done { color: rgb(34, 197, 94); }
.task-icon.blocked { color: rgb(239, 68, 68); } .task-icon.blocked { color: rgb(239, 68, 68); }
.task-icon.backlog { color: var(--text-muted); } .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 { .task-info {
flex: 1; flex: 1;
} }
@@ -441,7 +441,7 @@ onMounted(loadProject)
} }
.modal { .modal {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--line-2);
border-radius: 12px; border-radius: 12px;
padding: 1.5rem; padding: 1.5rem;
max-width: 420px; max-width: 420px;
+18 -18
View File
@@ -192,7 +192,7 @@ onMounted(loadStatus)
} }
.security-card { .security-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 9px; border-radius: var(--r);
background: var(--panel); background: var(--panel);
padding: 20px; padding: 20px;
display: flex; display: flex;
@@ -204,27 +204,27 @@ onMounted(loadStatus)
height: 40px; height: 40px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 9px; border-radius: var(--r);
color: #a99cff; color: var(--a-mid);
background: var(--accent-soft); background: var(--accent-soft);
} }
.security-card-icon.twofa-icon { .security-card-icon.twofa-icon {
color: #e5b05e; color: var(--st-queue);
background: rgba(229,176,94,.1); background: rgba(229,176,94,.1);
} }
.security-card-icon.passkey-icon { .security-card-icon.passkey-icon {
color: #6d9fe6; color: var(--a-blue);
background: rgba(109,159,230,.1); background: rgba(109,159,230,.1);
} }
.security-card h3 { .security-card h3 {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
color: #e8eaf0; color: var(--tx);
} }
.security-value { .security-value {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
} }
.policy-text { .policy-text {
font-size: 11px; font-size: 11px;
@@ -234,7 +234,7 @@ onMounted(loadStatus)
.security-desc { .security-desc {
margin: 0; margin: 0;
font-size: 10px; font-size: 10px;
color: #7e8799; color: var(--tx-3);
line-height: 1.5; line-height: 1.5;
} }
.security-detail-list { .security-detail-list {
@@ -250,14 +250,14 @@ onMounted(loadStatus)
} }
.security-detail-label { .security-detail-label {
font-size: 10px; font-size: 10px;
color: #8991a1; color: var(--tx-2);
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.security-detail-value { .security-detail-value {
font-size: 10px; font-size: 10px;
color: #e8eaf0; color: var(--tx);
font-weight: 500; font-weight: 500;
text-align: right; text-align: right;
word-break: break-word; word-break: break-word;
@@ -281,11 +281,11 @@ code.security-detail-value {
} }
.status-bool.enabled { .status-bool.enabled {
background: rgba(81,212,154,.1); background: rgba(81,212,154,.1);
color: #51d49a; color: var(--st-work);
} }
.status-bool.disabled { .status-bool.disabled {
background: rgba(225,110,117,.08); background: rgba(225,110,117,.08);
color: #e16e75; color: var(--st-block);
} }
.security-status-row { .security-status-row {
display: flex; display: flex;
@@ -293,16 +293,16 @@ code.security-detail-value {
gap: 6px; gap: 6px;
} }
.check-icon { .check-icon {
color: #51d49a; color: var(--st-work);
} }
.x-icon { .x-icon {
color: #e16e75; color: var(--st-block);
} }
.enabled-text { .enabled-text {
color: #51d49a; color: var(--st-work);
} }
.disabled-text { .disabled-text {
color: #e16e75; color: var(--st-block);
} }
.memory-status { .memory-status {
@@ -311,11 +311,11 @@ code.security-detail-value {
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 48px; padding: 48px;
color: #7e8799; color: var(--tx-3);
font-size: 12px; font-size: 12px;
} }
.memory-status.error { .memory-status.error {
color: #e16e75; color: var(--st-block);
} }
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
+29 -29
View File
@@ -409,7 +409,7 @@ onMounted(() => {
} }
.grad-text { .grad-text {
background: linear-gradient(135deg, #4f7cff, #b557f6); background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
@@ -418,7 +418,7 @@ onMounted(() => {
.settings-subtitle { .settings-subtitle {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 11px; font-size: 11px;
color: #6f6aa0; color: var(--tx-3);
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
} }
@@ -444,7 +444,7 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
color: #ece9ff; color: var(--tx);
padding-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid rgba(150, 140, 255, 0.08); border-bottom: 1px solid rgba(150, 140, 255, 0.08);
} }
@@ -470,7 +470,7 @@ onMounted(() => {
.field label { .field label {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #a8a3d6; color: var(--tx-2);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -490,7 +490,7 @@ onMounted(() => {
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 11px; border-radius: 11px;
background: rgba(10, 9, 24, 0.55); background: rgba(10, 9, 24, 0.55);
color: #ece9ff; color: var(--tx);
font-size: 13.5px; font-size: 13.5px;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
outline: none; outline: none;
@@ -513,13 +513,13 @@ onMounted(() => {
} }
.galaxy-select option { .galaxy-select option {
background: #141130; background: var(--space-3);
color: #ece9ff; color: var(--tx);
} }
.value-tag { .value-tag {
font-size: 14px; font-size: 14px;
color: #ece9ff; color: var(--tx);
font-weight: 500; font-weight: 500;
} }
@@ -538,7 +538,7 @@ onMounted(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15)); 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); border: 1px solid rgba(124, 108, 255, 0.15);
width: fit-content; width: fit-content;
} }
@@ -589,14 +589,14 @@ onMounted(() => {
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: #6f6aa0; color: var(--tx-3);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.pw-toggle:hover { .pw-toggle:hover {
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
color: #a8a3d6; color: var(--tx-2);
} }
/* ── Messages ──────────────────────────────────── */ /* ── Messages ──────────────────────────────────── */
@@ -613,13 +613,13 @@ onMounted(() => {
.msg.error { .msg.error {
background: rgba(244, 63, 94, 0.1); background: rgba(244, 63, 94, 0.1);
border: 1px solid rgba(244, 63, 94, 0.2); border: 1px solid rgba(244, 63, 94, 0.2);
color: #fda4af; color: var(--st-block);
} }
.msg.success { .msg.success {
background: rgba(34, 197, 94, 0.1); background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2); border: 1px solid rgba(34, 197, 94, 0.2);
color: #86efac; color: var(--st-work);
} }
/* ── Buttons ──────────────────────────────────── */ /* ── Buttons ──────────────────────────────────── */
@@ -630,7 +630,7 @@ onMounted(() => {
padding: 9px 16px; padding: 9px 16px;
border: none; border: none;
border-radius: 10px; 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; color: #fff;
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
@@ -665,7 +665,7 @@ onMounted(() => {
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
background: transparent; background: transparent;
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
color: #a8a3d6; color: var(--tx-2);
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
@@ -673,7 +673,7 @@ onMounted(() => {
.btn-ghost:hover { .btn-ghost:hover {
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
color: #ece9ff; color: var(--tx);
} }
/* ── Admin User Management ───────────────────── */ /* ── Admin User Management ───────────────────── */
@@ -711,7 +711,7 @@ onMounted(() => {
place-items: center; place-items: center;
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2)); 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); border: 1px solid rgba(124, 108, 255, 0.15);
color: #b8adff; color: var(--a-mid);
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 14px;
flex-shrink: 0; flex-shrink: 0;
@@ -725,12 +725,12 @@ onMounted(() => {
.user-name { .user-name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #ece9ff; color: var(--tx);
} }
.user-email { .user-email {
font-size: 11px; font-size: 11px;
color: #6f6aa0; color: var(--tx-3);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -754,31 +754,31 @@ onMounted(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
background: rgba(124, 108, 255, 0.1); background: rgba(124, 108, 255, 0.1);
color: #b8adff; color: var(--a-mid);
border: 1px solid rgba(124, 108, 255, 0.1); border: 1px solid rgba(124, 108, 255, 0.1);
} }
.role-tag.owner { .role-tag.owner {
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15)); 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); border-color: rgba(251, 191, 36, 0.2);
} }
.role-tag.admin { .role-tag.admin {
background: rgba(79, 124, 255, 0.12); background: rgba(79, 124, 255, 0.12);
color: #93c5fd; color: var(--a-blue);
border-color: rgba(79, 124, 255, 0.2); border-color: rgba(79, 124, 255, 0.2);
} }
.role-tag.viewer { .role-tag.viewer {
background: rgba(107, 103, 150, 0.15); background: rgba(107, 103, 150, 0.15);
color: #a8a3d6; color: var(--tx-2);
border-color: rgba(107, 103, 150, 0.2); border-color: rgba(107, 103, 150, 0.2);
} }
.user-date { .user-date {
font-size: 10px; font-size: 10px;
color: #6f6aa0; color: var(--tx-3);
} }
.loading-pulse { .loading-pulse {
@@ -806,7 +806,7 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 32px; padding: 32px;
color: #6f6aa0; color: var(--tx-3);
} }
.empty-state p { .empty-state p {
@@ -849,7 +849,7 @@ onMounted(() => {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: #ece9ff; color: var(--tx);
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
} }
@@ -861,7 +861,7 @@ onMounted(() => {
justify-content: center; justify-content: center;
border: none; border: none;
background: transparent; background: transparent;
color: #6f6aa0; color: var(--tx-3);
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
font-size: 20px; font-size: 20px;
@@ -870,11 +870,11 @@ onMounted(() => {
.modal-close:hover { .modal-close:hover {
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
color: #ece9ff; color: var(--tx);
} }
.req { .req {
color: #fb7185; color: var(--st-block);
} }
.modal-form { .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 { function priorityColor(p: string): string {
const lower = p.toLowerCase() const lower = p.toLowerCase()
if (lower === 'high') return '#f87171' if (lower === 'high') return 'var(--st-block)'
if (lower === 'low') return '#60a5fa' if (lower === 'low') return 'var(--a-blue)'
return '#facc15' return 'var(--st-queue)'
} }
function priorityLabel(p: string): string { function priorityLabel(p: string): string {
@@ -638,7 +638,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 10px; border-radius: 10px;
background: transparent; background: transparent;
color: #a8a3d6; color: var(--tx-2);
font-size: 12px; font-size: 12px;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
cursor: pointer; cursor: pointer;
@@ -648,7 +648,7 @@ function handleKeydown(e: KeyboardEvent) {
.back-btn:hover { .back-btn:hover {
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
color: #ece9ff; color: var(--tx);
} }
/* ── Loading / Error ────────────────────────── */ /* ── Loading / Error ────────────────────────── */
@@ -659,14 +659,14 @@ function handleKeydown(e: KeyboardEvent) {
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
padding: 80px 20px; padding: 80px 20px;
color: #6f6aa0; color: var(--tx-3);
} }
.spinner { .spinner {
width: 24px; width: 24px;
height: 24px; height: 24px;
border: 2.5px solid rgba(150, 140, 255, 0.15); border: 2.5px solid rgba(150, 140, 255, 0.15);
border-top-color: #7c6cff; border-top-color: var(--a-mid);
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
@@ -675,7 +675,7 @@ function handleKeydown(e: KeyboardEvent) {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid rgba(150, 140, 255, 0.15); border: 2px solid rgba(150, 140, 255, 0.15);
border-top-color: #7c6cff; border-top-color: var(--a-mid);
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
flex-shrink: 0; flex-shrink: 0;
@@ -686,7 +686,7 @@ function handleKeydown(e: KeyboardEvent) {
.error-state p { .error-state p {
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
color: #fda4af; color: var(--st-block);
} }
/* ── Detail Header ──────────────────────────── */ /* ── Detail Header ──────────────────────────── */
@@ -711,15 +711,15 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid transparent; 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-backlog { color: var(--st-queue); 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-progress { color: var(--st-work); 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-review { color: var(--st-queue); 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-blocked { color: var(--st-block); 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-done { color: var(--st-work); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.meta-chip { .meta-chip {
font-size: 10px; font-size: 10px;
color: #6f6aa0; color: var(--tx-3);
background: rgba(10, 9, 24, 0.35); background: rgba(10, 9, 24, 0.35);
padding: 3px 10px; padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
@@ -735,7 +735,7 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 10px; border-radius: 10px;
background: rgba(147,51,234,.08); background: rgba(147,51,234,.08);
border: 1px solid rgba(147,51,234,.18); border: 1px solid rgba(147,51,234,.18);
color: #c084fc; color: var(--a-purple);
font-size: 12px; font-size: 12px;
} }
@@ -759,7 +759,7 @@ function handleKeydown(e: KeyboardEvent) {
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
padding: 4px 0; padding: 4px 0;
color: #ece9ff; color: var(--tx);
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-size: 28px; font-size: 28px;
font-weight: 700; font-weight: 700;
@@ -777,7 +777,7 @@ function handleKeydown(e: KeyboardEvent) {
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
font-size: 12px; font-size: 12px;
color: #6f6aa0; color: var(--tx-3);
} }
.meta-row span { .meta-row span {
@@ -791,7 +791,7 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 12px; border-radius: 12px;
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
border: 1px solid rgba(124, 108, 255, 0.14); border: 1px solid rgba(124, 108, 255, 0.14);
color: #a8a3d6; color: var(--tx-2);
font-size: 12px; font-size: 12px;
} }
@@ -808,7 +808,7 @@ function handleKeydown(e: KeyboardEvent) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 14px; margin-bottom: 14px;
color: #a8a3d6; color: var(--tx-2);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -826,7 +826,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.10); border: 1px solid rgba(150, 140, 255, 0.10);
border-radius: 12px; border-radius: 12px;
background: rgba(10, 9, 24, 0.45); background: rgba(10, 9, 24, 0.45);
color: #ece9ff; color: var(--tx);
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
@@ -897,13 +897,13 @@ function handleKeydown(e: KeyboardEvent) {
.subtask-title { .subtask-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #ece9ff; color: var(--tx);
} }
.subtask-detail { .subtask-detail {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 11.5px; font-size: 11.5px;
color: #6f6aa0; color: var(--tx-3);
line-height: 1.4; line-height: 1.4;
} }
@@ -919,7 +919,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 6px; border-radius: 6px;
background: rgba(10, 9, 24, 0.5); background: rgba(10, 9, 24, 0.5);
color: #a8a3d6; color: var(--tx-2);
font-size: 10px; font-size: 10px;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
cursor: pointer; cursor: pointer;
@@ -927,8 +927,8 @@ function handleKeydown(e: KeyboardEvent) {
} }
.state-select-mini option { .state-select-mini option {
background: #141130; background: var(--space-3);
color: #ece9ff; color: var(--tx);
} }
.state-select-mini:disabled { .state-select-mini:disabled {
@@ -941,14 +941,14 @@ function handleKeydown(e: KeyboardEvent) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 16px; padding: 16px;
color: #6f6aa0; color: var(--tx-3);
font-size: 12px; font-size: 12px;
} }
.empty-section { .empty-section {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
color: #6f6aa0; color: var(--tx-3);
font-size: 12px; font-size: 12px;
} }
@@ -996,7 +996,7 @@ function handleKeydown(e: KeyboardEvent) {
display: grid; display: grid;
place-items: center; place-items: center;
background: rgba(124, 108, 255, 0.08); background: rgba(124, 108, 255, 0.08);
color: #7c6cff; color: var(--a-mid);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1007,13 +1007,13 @@ function handleKeydown(e: KeyboardEvent) {
.activity-msg { .activity-msg {
font-size: 12.5px; font-size: 12.5px;
color: #a8a3d6; color: var(--tx-2);
line-height: 1.4; line-height: 1.4;
} }
.activity-time { .activity-time {
font-size: 10px; font-size: 10px;
color: #6f6aa0; color: var(--tx-3);
margin-top: 3px; margin-top: 3px;
} }
@@ -1042,7 +1042,7 @@ function handleKeydown(e: KeyboardEvent) {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #a8a3d6; color: var(--tx-2);
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -1060,7 +1060,7 @@ function handleKeydown(e: KeyboardEvent) {
.sidebar-field span { .sidebar-field span {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #6f6aa0; color: var(--tx-3);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
@@ -1079,13 +1079,13 @@ function handleKeydown(e: KeyboardEvent) {
} }
.info-list dt { .info-list dt {
color: #6f6aa0; color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.info-list dd { .info-list dd {
margin: 0; margin: 0;
color: #a8a3d6; color: var(--tx-2);
font-size: 11.5px; font-size: 11.5px;
text-align: right; text-align: right;
} }
@@ -1104,7 +1104,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 10px; border-radius: 10px;
background: rgba(10, 9, 24, 0.55); background: rgba(10, 9, 24, 0.55);
color: #ece9ff; color: var(--tx);
font-size: 13.5px; font-size: 13.5px;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
outline: none; outline: none;
@@ -1127,8 +1127,8 @@ function handleKeydown(e: KeyboardEvent) {
} }
.galaxy-select option { .galaxy-select option {
background: #141130; background: var(--space-3);
color: #ece9ff; color: var(--tx);
} }
.galaxy-select:disabled { .galaxy-select:disabled {
@@ -1143,7 +1143,7 @@ function handleKeydown(e: KeyboardEvent) {
padding: 9px 16px; padding: 9px 16px;
border: none; border: none;
border-radius: 10px; 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; color: #fff;
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
@@ -1169,7 +1169,7 @@ function handleKeydown(e: KeyboardEvent) {
border: 1px solid rgba(150, 140, 255, 0.12); border: 1px solid rgba(150, 140, 255, 0.12);
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: #a8a3d6; color: var(--tx-2);
font-size: 11px; font-size: 11px;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
cursor: pointer; cursor: pointer;
@@ -1186,13 +1186,13 @@ function handleKeydown(e: KeyboardEvent) {
border: none; border: none;
border-radius: 7px; border-radius: 7px;
background: rgba(124, 108, 255, 0.06); background: rgba(124, 108, 255, 0.06);
color: #a8a3d6; color: var(--tx-2);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: #ece9ff; } .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: #fda4af; } .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; } .btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
.msg { .msg {
@@ -1202,8 +1202,8 @@ function handleKeydown(e: KeyboardEvent) {
border-radius: 8px; border-radius: 8px;
} }
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: #fda4af; } .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: #86efac; display: flex; align-items: center; gap: 6px; } .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 ──────────────────────────────── */ /* ── Responsive ──────────────────────────────── */
@media (max-width: 860px) { @media (max-width: 860px) {
+12 -12
View File
@@ -23,7 +23,7 @@ const agents: AgentCardData[] = [
role: 'Chief of Staff', role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.', description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'], tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6', color: '#7c6cff',
icon: 'bot', icon: 'bot',
hero: true, hero: true,
}, },
@@ -33,7 +33,7 @@ const agents: AgentCardData[] = [
role: 'Lead Developer', role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.', description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['coding', 'development', 'builds'], tags: ['coding', 'development', 'builds'],
color: '#3b82f6', color: '#4f7cff',
icon: 'code', icon: 'code',
}, },
{ {
@@ -42,7 +42,7 @@ const agents: AgentCardData[] = [
role: 'Code QA', role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.', description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['Quality Assurance', 'Security', 'Code Review'], tags: ['Quality Assurance', 'Security', 'Code Review'],
color: '#a855f7', color: '#b557f6',
icon: 'shield', icon: 'shield',
}, },
{ {
@@ -51,7 +51,7 @@ const agents: AgentCardData[] = [
role: 'Research Analyst', role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.', description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'], tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#22c55e', color: '#3ddc97',
icon: 'search', icon: 'search',
}, },
{ {
@@ -60,7 +60,7 @@ const agents: AgentCardData[] = [
role: 'Host Executor', role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.', description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Deployment', 'VPS'], tags: ['Execution', 'Deployment', 'VPS'],
color: '#eab308', color: '#fbbf24',
icon: 'terminal', icon: 'terminal',
}, },
] ]
@@ -133,7 +133,7 @@ function goToAgent(id: string) {
.quote-text { .quote-text {
font-style: italic; font-style: italic;
font-size: 12px; font-size: 12px;
color: #9ea5b3; color: var(--tx-2);
line-height: 1.5; line-height: 1.5;
} }
@@ -144,17 +144,17 @@ function goToAgent(id: string) {
.team-title { .team-title {
font-size: 26px; font-size: 26px;
font-weight: 600; font-weight: 600;
color: #e8eaf0; color: var(--tx);
margin: 0 0 6px; margin: 0 0 6px;
} }
.team-subtitle { .team-subtitle {
font-size: 12px; font-size: 12px;
color: #7e8799; color: var(--tx-3);
margin: 0 0 4px; margin: 0 0 4px;
} }
.team-description { .team-description {
font-size: 10.5px; font-size: 10.5px;
color: #6b7385; color: var(--tx-3);
margin: 0; margin: 0;
max-width: 560px; max-width: 560px;
margin-left: auto; margin-left: auto;
@@ -182,7 +182,7 @@ function goToAgent(id: string) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 10px; font-size: 10px;
color: #7e8799; color: var(--tx-3);
} }
.legend-dot { .legend-dot {
width: 8px; width: 8px;
@@ -191,11 +191,11 @@ function goToAgent(id: string) {
flex-shrink: 0; flex-shrink: 0;
} }
.active-pulse { .active-pulse {
background: #51d49a; background: var(--st-work);
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6); box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
} }
.idle-pulse { .idle-pulse {
background: #3a3f4b; background: var(--line-2);
} }
.pulse-dot { .pulse-dot {
background: white; background: white;
+3 -2
View File
@@ -1,6 +1,6 @@
# Runtime und Routing # Runtime und Routing
> Letzte Aktualisierung: 2026-06-16 > Letzte Aktualisierung: 2026-07-09
## Aktive Modelle (7 von 8 konfiguriert) ## Aktive Modelle (7 von 8 konfiguriert)
@@ -23,6 +23,7 @@
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime` - Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing) - 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)) - **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) - Vollständige Architektur- und Sicherheitsanalyse: [architecture-board-first-orchestration.md](../docs/architecture-board-first-orchestration.md)