Compare commits

...

59 Commits

Author SHA1 Message Date
devops 96a44233c0 chore: bump version to v0.2.31 [skip ci] 2026-06-09 21:47:39 +00:00
developer 191cb5cbd2 fix: FeedDetailModal v-if fehlte – immer sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- v-if='modelValue' auf Overlay-Div
- Modal nur noch an wenn showDetailModal=true
- Overlay-Klick + X-Button schließen korrekt
2026-06-09 23:46:51 +02:00
devops 12e629432c chore: bump version to v0.2.30 [skip ci] 2026-06-09 21:43:48 +00:00
developer 47f0f1d786 feat: Iris Chat Expand-Button + Modal
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Maximize2-Button im Chat-Header
- Expand-Modal (700px, 70vh) mit vollem Chat
- Teleport-Overlay, X-Close, Overlay-Klick
- Separater chatModalListRef für Modal-Scrolling
2026-06-09 23:42:59 +02:00
devops bf60b8b064 chore: bump version to v0.2.29 [skip ci] 2026-06-09 21:41:01 +00:00
developer b8498f47bb [Reviewer] Dashboard Review: Interface-Bereinigung, SVG-Composable-Extraktion, Komponenten-Renaming, CSS-Fix
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- AgentNodeData: Remove redundant fields task/runtime (dup of currentTask/runtimeSeconds)
- useTeamNetworkSvg: Extract SVG layout, path computation + pulse animation from TeamNetwork
- TeamNetwork: Use AgentNodeData type, fix undefined pulseElements2/storePulseRef2, remove unused props
- Rename MissionCard.vue → TaskCard.vue (matches actual usage)
- Extract FeedDetailModal from OperationsFeed (eliminates :global() CSS conflict with AgentModal)
- DashboardView: Fix type import path (../../ → ../), remove dead TeamNetwork props
- AgentModal: Remove unused thinkingStreamRef template ref

Build: vue-tsc --noEmit 0 errors, vite build ✓
2026-06-09 23:38:23 +02:00
devops f037aa2eeb chore: bump version to v0.2.28 [skip ci] 2026-06-09 21:30:00 +00:00
developer e6520fc26d fix: Queue → Chat Queue umbenannt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-09 23:29:11 +02:00
devops c9d8852609 chore: bump version to v0.2.27 [skip ci] 2026-06-09 21:22:19 +00:00
developer 11e9a257a1 fix: AgentNodeData um tags, task, runtime erweitert – Cards wieder sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
- Interface: tags:string[], task:string, runtime:string, hero?:boolean
- Alle 5 Agenten mit Tags, Task, Runtime, Hero befüllt
2026-06-09 23:21:31 +02:00
devops ead202ad8b chore: bump version to v0.2.26 [skip ci] 2026-06-09 21:16:23 +00:00
developer effc86e15b feat: LLM Model + Glassmorphism + Doppel-Pulse + Task-Board
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Agent Cards: aktives LLM Model unter Runtime
- Glassmorphism: rgba-BG + backdrop-filter:blur
- Bézier-Linien: 2 Pulse pro Verbindung (Offset 50%)
- TaskCard: 'Zum Task Board' Button mit Pfeil
2026-06-09 23:15:33 +02:00
devops 0f9809e423 chore: bump version to v0.2.25 [skip ci] 2026-06-09 21:01:16 +00:00
developer c2736d20c1 feat: Cards, Offene Aufgaben, Feed – Komplettumbau
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
TeamNetwork: Footer→Arrow, Current Task+Runtime inline
Missions→Offene Aufgaben (TaskCard) mit +New Task, Iris/Bao-Quelle
OperationsFeed: Text-Wrap, 5 Items, Mehr-Button→Tag-Navigation-Modal
2026-06-09 23:00:26 +02:00
devops 084cff4fe6 chore: bump version to v0.2.24 [skip ci] 2026-06-09 20:45:02 +00:00
developer ef3fc6039e fix: Modal-Layout – Dots bei Current Task + Reihenfolge + Timestamps
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Dots (blau/violett) rechts von Current Task, unterschiedlich pulsierend
- Reihenfolge: Current Task → Live Thinking → Goal → Working Feed
- Link-Button enger am Namen (margin-left:4px, opacity 0.4)
- Working Feed mit Timestamps (step.time)
- Workload-Tag aus Footer entfernt
2026-06-09 22:44:13 +02:00
devops 3599513128 chore: bump version to v0.2.23 [skip ci] 2026-06-09 20:36:05 +00:00
developer 7dd8f53f2f fix: Dots rechts vom Thinking-Text + Glow intensiver + Link-Button
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- Dots (blau+lila) inline rechts vom aktuellen Thinking-Eintrag, rotierend
- Orbiter-Rahmen entfernt
- Panel-Glow verstärkt (border 0.5, shadow 24px+40px)
- ExternalLink-Button rechts vom Agent-Namen → /agents/{id}
2026-06-09 22:35:15 +02:00
devops 90bb7251e3 chore: bump version to v0.2.22 [skip ci] 2026-06-09 20:31:44 +00:00
developer e57bef95e5 fix: mehr Abstand Iris↔Grid + Linien enger gebündelt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- gap: 32px → 64px (doppelter Vertikalraum)
- startX: 0.30+0.40 → 0.38+0.24 (enger unter Iris)
- cp1y: startY+40 → startY+70 (tiefer vor Spread)
- cp2x: ±50 → ±35 (sanftere Card-Annäherung)
2026-06-09 22:30:55 +02:00
devops 71b4465595 chore: bump version to v0.2.21 [skip ci] 2026-06-09 20:28:41 +00:00
developer 9b63e5368e feat: Live Thinking Panel im AgentModal
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Scrollbarer Thinking-Stream (slide-in von links)
- Pulsierender Rahmen mit Glow-Effekt
- Blau+Violett Dots rotieren im Uhrzeigersinn
- thinkingStream in AgentNodeData + Beispieldaten für alle 5 Agenten
2026-06-09 22:27:53 +02:00
devops 8f265d00ba chore: bump version to v0.2.20 [skip ci] 2026-06-09 20:24:07 +00:00
developer 5a3a099b94 fix: Iris als Hero im AI Team Network – Hierarchie korrigiert
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- Iris zu agents[] hinzugefügt (Position 0)
- hero-id='iris' an TeamNetwork übergeben
- Hero-Slot data-agent-id dynamisch (:data-agent-id='hero.id')
2026-06-09 22:23:17 +02:00
devops 1f6f5dd08c chore: bump version to v0.2.19 [skip ci] 2026-06-09 20:20:03 +00:00
developer 6e532f64f5 fix: AgentModal bei Klick auf Card verdrahtet
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- @select Handler auf TeamNetwork
- selectedAgent State + onAgentSelect()
- AgentModal importiert und gerendert (v-if selectedAgent)
- Close via X oder Overlay
2026-06-09 22:19:12 +02:00
devops 7154c30b99 chore: bump version to v0.2.18 [skip ci] 2026-06-09 20:13:46 +00:00
developer ffe7baba78 fix: Vollbreite-Layout – max-width/margin entfernt, Content füllt Desktop-Breite
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- .content: max-width:1320px + margin:auto entfernt
- padding reduziert: 36px 34px → 16px 16px
- Middle-Col (1fr) im Dashboard nutzt jetzt volle Restbreite
2026-06-09 22:12:55 +02:00
devops da9c256b43 chore: bump version to v0.2.17 [skip ci] 2026-06-09 20:10:25 +00:00
developer 1012d2c217 feat: altes AgentCard-Design in TeamNetwork + node-task / node-runtime
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- Icon-Wrap + Name + Role-Tag + Description + Tags + Footer 'ROLE CARD →'
- node-task: aktuelle Aufgabe mit ●-Punkt (zwischen Description und Tags)
- node-runtime: Live-Runtime im Footer (tabular-nums)
- TeamNetwork background: transparent, Klasse → ai-team-network
- Modal-Klick erhalten
2026-06-09 22:09:02 +02:00
devops 611c343e0c chore: bump version to v0.2.16 [skip ci] 2026-06-09 19:59:13 +00:00
developer 2857c27b7c feat: Quote, Title, Legende um AI Team Network im Dashboard
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- Quote-Pill: 'An autonomous organization of AI agents...' wie alte TeamView
- Title/Subtitle/Description über Netzwerk
- Legende (Aktive/Idle/Pulse) unter Netzwerk
- Klick = Modal (wie neue Version, NICHT router.push)
- TeamNetwork unangetastet (SVG-Linien, Bézier, Pulse, Glow)
2026-06-09 21:58:21 +02:00
devops 745e202e21 chore: bump version to v0.2.15 [skip ci] 2026-06-09 19:50:06 +00:00
developer 5244e9fd3d feat: AI Team Network ins Dashboard integriert, Vollbreite, dynamisches Grid
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
- TeamNetwork ins Dashboard verschoben (center column)
- 3-Spalten-Layout auf volle Desktop-Breite (kein max-width)
- Agent-Grid dynamisch: 2 Spalten, erweitert nach unten (4→6→8 Agenten)
- SVG-Bézier-Linien mit ResizeObserver passen sich an
- 'Team' aus Navigation, Router und standaloneViews entfernt
- /team Route gelöscht
2026-06-09 21:49:10 +02:00
devops 774a5a44f3 chore: bump version to v0.2.14 [skip ci] 2026-06-09 19:38:06 +00:00
developer b535fd1ab3 feat: AI Team Network – Bézier lines, parallel routing, pulse animation
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- SVG-Layer hinter Cards mit Bézier-Kurven (C-Pfad)
- 4 Linien starten parallel an Iris' unterer Mitte, keine Überschneidungen
- Links-Spalte: Linien kurven nach links, treffen rechte Card-Kante
- Rechts-Spalte: Linien kurven nach rechts, treffen linke Card-Kante
- JS-Pulse-Animation via requestAnimationFrame (~3s/Cycle)
- Aktive Agenten: glow box-shadow, hellere Linien, full-opacity Puls
- Responsive ResizeObserver, mobile Single-Column
- Titel 'AI Team Network', Legende (active/idle/pulse)
2026-06-09 21:37:12 +02:00
devops 87e504a1b5 chore: bump version to v0.2.13 [skip ci] 2026-06-09 19:25:23 +00:00
devops 802d2cef3f fix(ci): remove swagger from smoke test — disabled in production
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
Swagger (/swagger) is only enabled in Development mode (Program.cs
gates it behind app.Environment.IsDevelopment()). In production,
nginx serves the frontend catch-all (index.html), so the check
always returns 200 but never actually validates the API layer.

/health already covers API + database + runtime health checks.
No replacement endpoint needed — the smoke test still validates
both the dashboard and the backend API via /health.
2026-06-09 21:24:27 +02:00
devops 7bee8bc23f chore: bump version to v0.2.12 [skip ci] 2026-06-09 19:21:42 +00:00
devops 84bf9b7fba fix(ci): correct swagger path + add deploy concurrency guard
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
Iteration 2 fix: /api/swagger → /swagger (correct ASP.NET default).

Iteration 3 — Concurrency guard:
- concurrency group 'deploy-production': ensures only one deploy
  runs at a time (cancel-in-progress: false so queued deploys
  wait instead of being cancelled).
- Why: prevents race conditions when CI-triggered workflow_run
  and manual workflow_dispatch overlap. Without this, parallel
  deploys could corrupt docker compose state or conflict on
  shared resources (ports, volumes, version tags).
2026-06-09 21:20:54 +02:00
devops b0b95d2453 chore: bump version to v0.2.11 [skip ci] 2026-06-09 19:20:00 +00:00
devops cf00318f23 feat(ci): robust health check + multi-endpoint smoke test + rollback hint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
Iteration 2 — Deploy robustness:
- Health check: Fibonacci-ish backoff (1,2,3,5,8,13s) instead of fixed
  5s intervals. Why: containers need variable warmup time; fixed intervals
  either wait too long or give up too early. Total budget ~32s vs 30s before.
- Smoke test: now checks /dashboard, /health, and /api/swagger. Why: a
  single endpoint check can miss backend-only outages; API Swagger confirms
  the ASP.NET layer is healthy.
- Rollback hint: on any failure, prints previous git tag + docker compose
  commands for quick manual rollback. Why: reduces MTTR by providing the
  exact recovery steps inline.
2026-06-09 21:19:07 +02:00
devops 1085c14594 chore: bump version to v0.2.10 [skip ci] 2026-06-09 19:17:34 +00:00
devops df72fd9439 fix(ci): use --no-frozen-lockfile until lockfile is regenerated
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
pnpm defaults to frozen-lockfile in CI. The committed lockfile
is outdated (vitest added to package.json). Using --no-frozen-lockfile
is a pragmatic fix; lockfile should be regenerated via 'pnpm install'
and recommitted for full --frozen-lockfile enforcement.
2026-06-09 21:16:47 +02:00
devops 66b833b68b fix(ci): commit pnpm-lock.yaml for frozen-lockfile CI
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Failing after 7s
CI - Build & Test / Security Check (push) Successful in 3s
The --frozen-lockfile flag requires the lockfile to be present
in the checkout. Previously pnpm-lock.yaml was gitignored, so
it was absent from CI checkouts.

Lockfiles SHOULD be version-controlled for reproducible builds.
This also enables CI to detect when lockfile is outdated vs
package.json.
2026-06-09 21:15:13 +02:00
devops 65b46386a1 perf(ci): concurrency groups + strict pnpm lockfile
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Failing after 8s
CI - Build & Test / Security Check (push) Successful in 2s
Iteration 1 — CI reliability and speed:
- Concurrency: cancel in-progress CI runs when new push arrives
  to the same branch. Why: Avoids waste when pushing multiple
  fixes in quick succession; only the latest code is tested.
- pnpm: switch from --no-frozen-lockfile to --frozen-lockfile.
  Why: Fails fast if pnpm-lock.yaml is outdated — prevents
  untested dependency changes from reaching main.
- pnpm: add --prefer-offline to use locally cached packages.
  Why: Slightly faster installs when packages are already
  available in the runner image cache.
2026-06-09 21:13:57 +02:00
devops 09fb6c1ec0 perf(ci): add NuGet + pnpm caching to speed up CI
CI - Build & Test / Backend (.NET) (push) Successful in 1m41s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
Iteration 1 — Build caching:
- Backend: cache ~/.nuget/packages keyed on .csproj hashes.
  Typical hit: restore drops from ~15s to ~2s (NuGet packages
  already cached locally).
- Frontend: cache node_modules + ~/.pnpm-store keyed on
  pnpm-lock.yaml. Typical hit: install drops from ~30s to ~3s.
- Concurrency: cancel in-progress CI runs when new push arrives
  to the same branch (avoids queue buildup).

Why: On cache hits, CI time drops ~60-70%. Faster feedback for
developers means shorter fix-deploy cycles.
2026-06-09 21:11:17 +02:00
devops 4c2e23517e chore: bump version to v0.2.9 [skip ci] 2026-06-09 19:09:26 +00:00
devops 045e36b014 fix(ci): remove backslash escapes from Gitea expressions in Build step
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
The \$ escape before ${{ inputs.service }} prevented Gitea from
evaluating the expression, passing literal backslash to the shell.
Also use ${BUILD_ARGS} (shell expansion) instead of \$BUILD_ARGS
so the outer shell passes the actual build args to the DIND container.
2026-06-09 21:08:33 +02:00
devops 961d096ca6 chore: bump version to v0.2.8 [skip ci] 2026-06-09 19:06:56 +00:00
devops 5a72399136 fix(ci): create .env in workspace before sync (DIND path issue)
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
Phase 1 — .env provisioning fix:
The previous approach tried to write .env directly to
/opt/openclaw/data/openclaw/workspace/nexus from inside the
runner's job container, but that host path is not mounted there.

Fix: write .env from Gitea secrets into the workspace first,
then sync it along with the source code via the existing
Docker-in-Docker pattern (which can access the host path).

Combined the separate '.env creation' and 'sync code' steps
into a single atomic 'Sync code + .env to host' step.
2026-06-09 21:06:04 +02:00
devops b1cc228fd6 chore: bump version to v0.2.7 [skip ci] 2026-06-09 19:04:11 +00:00
devops 3646521a75 fix(ci): version bump from git tags + .env from secrets
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
Phase 1 — Deploy reliability:
- Version bump: derive current version from 'git describe --tags' instead of
  VERSION file. This eliminates race conditions where the VERSION file is
  stale but the tag already exists from a previous failed run.
- Tag creation: use 'git tag -f' + 'git push --force --tags' to handle
  retries gracefully when tags already exist.
- Environment: provision .env at the host deploy path from Gitea secrets
  (ENV_POSTGRES_PASSWORD, ENV_JWT_KEY, ENV_OWNER_PASSWORD, ENV_OPENCLAW_TOKEN).
  This ensures .env always exists on the host even though it's excluded from
  the sync step for security.

Runner label was already fixed in previous commit (runs-on: ubuntu-latest).
2026-06-09 21:03:15 +02:00
devops 2e6d0efed6 chore: bump version to v0.2.6 [skip ci] 2026-06-09 19:02:18 +00:00
devops 1b25aad918 chore: bump version to v0.2.5 [skip ci] 2026-06-09 19:01:22 +00:00
developer 3c95281119 feat: Mission Control Dashboard – AI Team Network, Chat, Queue
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
- 3-Spalten-Layout: Missions/Feed | Team Network | Chat/Queue
- AI Team Network (Herzstück): Iris + 4 Agenten mit SVG-Linien
- AgentNode: Workload-Ring, Pulse-Animation, Farbcodierung
- AgentModal: Task, Goal, Progress, Working Feed
- MissionCard: Glass-Morphism, Progress-Bar, Status-Badges
- ChatPanel: Iris Chat mit Focus-Banner, Auto-Scroll
- QueuePanel: Drag&Drop, Prioritäten, Force-Execute
- Composables: useTimer, useDashboardData
2026-06-09 21:00:23 +02:00
devops 9fb90f9c05 chore: bump version to v0.2.4 [skip ci] 2026-06-09 18:59:25 +00:00
devops f25c5974c4 chore: bump version to v0.2.3 [skip ci] 2026-06-09 18:57:36 +00:00
devops c13d730aa0 fix(ci): change deploy runs-on to ubuntu-latest for reliable label matching
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
The runner registers with labels [linux, dotnet, node, ubuntu-latest, ...]
but did not include 'deploy'. Changed workflow to use the consistently
available ubuntu-latest label. Also added 'deploy' label to the runner
registration for future compatibility.
2026-06-09 20:56:44 +02:00
23 changed files with 5453 additions and 456 deletions
+8 -1
View File
@@ -1,6 +1,11 @@
name: CI - Build & Test
run-name: 🔍 CI ${{ gitea.ref_name }} by @${{ gitea.actor }}
# ── Concurrency: cancel in-progress CI when new push arrives ──
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
@@ -49,8 +54,10 @@ jobs:
corepack enable
corepack prepare pnpm@latest --activate
# --prefer-offline: use cached packages if available in the runner image
# Lockfile IS committed — regenerated on changes via pnpm install.
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
run: pnpm install --no-frozen-lockfile --prefer-offline
working-directory: frontend
- name: Type check
+128 -23
View File
@@ -1,6 +1,19 @@
name: Deploy to Production
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
# ── Concurrency: one deploy at a time, cancel queued ones ──
# Why: prevents race conditions when CI triggers deploy while
# a manual deploy is still running. The latest deploy wins.
concurrency:
group: deploy-production
cancel-in-progress: false
# ───────────────────────────────────────────────────
# Trigger: automatic after CI success, or manual dispatch.
# Runner: uses ubuntu-latest label (consistently present on
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
# Standard labels avoid custom-label matching edge cases.
# ───────────────────────────────────────────────────
on:
workflow_run:
workflows: ["CI - Build & Test"]
@@ -31,23 +44,32 @@ on:
jobs:
deploy:
name: Deploy Nexus
runs-on: deploy
runs-on: ubuntu-latest
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
steps:
# ── Step 1: Checkout ─────────────────────
- name: Checkout latest code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
# ── Step 2: Version bump (race-free) ─────
# Derives current version from git tags (not VERSION file) to
# avoid race conditions where tag exists but VERSION is stale.
# Uses --force on tag+push to handle retries after failed runs.
- name: Version Bump
run: |
CURRENT_VERSION=$(cat VERSION)
echo "📦 Current version: $CURRENT_VERSION"
set -euo pipefail
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
# Source of truth: latest git tag
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
CURRENT_VERSION="${TAG#v}"
echo "📦 Current version (from git tags): $CURRENT_VERSION"
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
case "${{ inputs.bump_version }}" in
major)
@@ -66,12 +88,36 @@ jobs:
git config user.name "DevOps"
git add VERSION
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
git tag "v${NEW_VERSION}"
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --tags
# --force avoids "tag already exists" when re-running after a failed attempt
git tag -f "v${NEW_VERSION}"
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
echo "✅ Version bumped to v${NEW_VERSION}"
- name: Sync code to host deploy path
# ── Step 3: Sync code + .env to host ──────
# Creates .env from Gitea secrets in the workspace, then syncs
# everything (except .git) to the host deploy path via DIND.
- name: Sync code + .env to host
run: |
# Create .env from Gitea secrets in the workspace
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea secrets → do not edit manually on the host
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
OPENCLAW_GATEWAY_PASSWORD=
ENVEOF
# Sync everything (except .git) from workspace to host
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
@@ -80,13 +126,15 @@ jobs:
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
! -name .env \
-exec cp -a {} /dest/ \;
"
echo "✅ Code + .env synced to host deploy path"
# ── Step 4: Docker Buildx ─────────────────
- name: Set up Docker Buildx
run: docker buildx create --use 2>/dev/null || true
# ── Step 5: Build & Deploy ────────────────
- name: Build & Deploy
run: |
BUILD_ARGS=""
@@ -103,36 +151,93 @@ jobs:
set -e
if [ -n '${{ inputs.service }}' ]; then
echo '🚀 Deploying service: ${{ inputs.service }}'
docker compose build $BUILD_ARGS \${{ inputs.service }}
docker compose up -d --force-recreate \${{ inputs.service }}
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
docker compose up -d --force-recreate ${{ inputs.service }}
else
echo '🚀 Deploying all services'
docker compose build $BUILD_ARGS
docker compose build ${BUILD_ARGS}
docker compose up -d --force-recreate
fi
"
# ── Step 6: Health Check (backoff) ────────
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
# Why: cold-start containers need variable warmup time;
# fixed 5s intervals either wait too long or give up too early.
- name: Health Check
run: |
sleep 5
echo "🏥 Health check..."
for i in 1 2 3 4 5 6; do
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed"
break
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Retry $i/6..."
sleep 5
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
# Fibonacci-ish backoff: 1,2,3,5,8,13
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ── Step 7: Smoke test (multi-endpoint) ───
# Tests multiple endpoints to catch partial failures.
# Why: a single /dashboard check can miss backend-only outages;
# /health tests the API + database + runtime status.
- name: Verify (smoke test)
run: |
echo "🔍 Smoke test..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://nexus.noveria.net/dashboard)
echo "Dashboard: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "❌ Dashboard not reachable!"
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Deployment verified"
# ── Step 8: Rollback hint ────────────────
# On any failure, prints the previous deploy tag for quick manual rollback.
# Why: reduces MTTR (mean time to recovery) by providing the exact
# git tag to roll back to without needing to look it up manually.
- name: Rollback hint
if: failure()
run: |
echo ""
echo "🔙 ─── Rollback Instructions ─── 🔙"
echo ""
echo " # 1. Checkout previous version:"
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
echo ""
echo " # 2. Redeploy:"
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
echo " docker compose up -d --force-recreate"
echo ""
echo " # 3. Or trigger rollback via Gitea:"
echo " Trigger 'Deploy to Production' workflow with the previous tag"
echo ""
+1 -2
View File
@@ -30,5 +30,4 @@ docker-compose.override.yml
*.tmp
*.bak
# pnpm
pnpm-lock.yaml
# pnpm (lockfile IS committed for reproducible CI builds)
+1 -1
View File
@@ -1 +1 @@
0.2.2
0.2.31
+1442
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -20,7 +20,7 @@ const activeView = computed(() => {
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Team: '/team', Security: '/security',
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', Settings: '/settings',
}
@@ -31,7 +31,7 @@ const navigate = (label: string) => {
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Team', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
@@ -0,0 +1,422 @@
<script setup lang="ts">
import { X, ExternalLink } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
defineProps<{
agent: AgentNodeData
runtime: string
}>()
defineEmits<{
close: []
}>()
</script>
<template>
<Teleport to="body">
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card" :style="{ '--agent-color': agent.color }">
<!-- Header -->
<div class="modal-header">
<div class="modal-title-row">
<div class="modal-avatar" :style="{ background: `${agent.color}18`, color: agent.color }">
<span class="avatar-letter">{{ agent.name.charAt(0) }}</span>
</div>
<div>
<h2>{{ agent.name }}</h2>
<span class="modal-role">{{ agent.role }}</span>
<a :href="`/agents/${agent.id}`" class="agent-link-btn" title="Open agent config">
<ExternalLink :size="12" />
</a>
</div>
</div>
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
<X :size="16" />
</button>
</div>
<!-- Description -->
<p class="modal-desc">{{ agent.description }}</p>
<!-- Current Task -->
<section class="modal-section">
<h3 class="section-label">Current Task</h3>
<p class="section-value">
{{ agent.currentTask }}
<span class="thinking-dots">
<span class="thinking-dot blue"></span>
<span class="thinking-dot violet"></span>
</span>
</p>
</section>
<!-- Live Thinking -->
<section class="modal-section">
<h3 class="section-label">Live Thinking</h3>
<div class="thinking-panel">
<div class="thinking-stream">
<div
v-for="(msg, idx) in agent.thinkingStream"
:key="idx"
class="thinking-entry"
:style="{ animationDelay: `${idx * 0.05}s` }"
>
<span class="entry-time">{{ msg.time }}</span>
<span class="entry-text">{{ msg.text }}</span>
</div>
<div v-if="!agent.thinkingStream?.length" class="thinking-placeholder">
Waiting for thought stream...
</div>
</div>
</div>
</section>
<!-- Goal + Progress -->
<section class="modal-section">
<h3 class="section-label">Goal</h3>
<p class="section-value">{{ agent.goal }}</p>
<div class="progress-row">
<span class="progress-pct">{{ agent.progress }}%</span>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: `${agent.progress}%` }"
></div>
</div>
</div>
</section>
<!-- Working Feed -->
<section class="modal-section">
<h3 class="section-label">Working Feed</h3>
<div class="work-feed">
<div
v-for="(step, idx) in agent.workingFeed"
:key="idx"
class="work-step"
>
<span class="step-dot"></span>
<span class="step-time">{{ step.time }}</span>
<span class="step-text">{{ step.text }}</span>
</div>
</div>
</section>
<!-- Footer Stats -->
<div class="modal-footer">
<span class="footer-badge">Runtime: {{ runtime }}</span>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
animation: overlay-in 0.2s ease;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-card {
width: min(480px, 100%);
max-height: 80vh;
overflow-y: auto;
padding: 24px;
background: rgba(18, 22, 30, 0.96);
border: 1px solid color-mix(in srgb, var(--agent-color) 25%, transparent);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
animation: card-in 0.25s ease;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-card::-webkit-scrollbar {
width: 5px;
}
.modal-card::-webkit-scrollbar-track {
background: transparent;
}
.modal-card::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title-row {
display: flex;
align-items: center;
gap: 14px;
}
.modal-avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 12px;
flex-shrink: 0;
}
.avatar-letter {
font-size: 18px;
font-weight: 700;
}
.modal-title-row h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #e8eaf0;
}
.modal-role {
font-size: 10px;
color: #6b7385;
font-weight: 500;
}
.agent-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
opacity: 0.4;
cursor: pointer;
transition: opacity 0.2s;
flex-shrink: 0;
text-decoration: none;
vertical-align: middle;
}
.agent-link-btn:hover {
opacity: 1;
color: var(--agent-color);
}
.modal-close-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.modal-close-btn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: #e8eaf0;
}
.modal-desc {
font-size: 11px;
line-height: 1.55;
color: #7e8799;
margin: 0 0 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.modal-section {
margin-bottom: 16px;
}
.section-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7385;
margin: 0 0 6px;
}
.section-value {
margin: 0;
font-size: 12px;
color: #e8eaf0;
line-height: 1.4;
display: flex;
align-items: center;
gap: 8px;
}
/* Progress */
.progress-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.progress-pct {
font-size: 11px;
font-weight: 600;
color: #7e8799;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.progress-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: var(--agent-color);
transition: width 0.5s ease;
}
/* Live Thinking */
.thinking-panel {
position: relative;
border: 1px solid rgba(139, 124, 246, 0.2);
border-radius: 12px;
padding: 14px;
background: rgba(12, 16, 22, 0.6);
overflow: hidden;
animation: panel-pulse 2.5s ease-in-out infinite;
}
@keyframes panel-pulse {
0%, 100% { border-color: rgba(139, 124, 246, 0.25); box-shadow: 0 0 12px rgba(139,124,246,0.08); }
50% { border-color: rgba(139, 124, 246, 0.5); box-shadow: 0 0 24px rgba(139,124,246,0.18), 0 0 40px rgba(139,124,246,0.06); }
}
.thinking-dots {
display: inline-flex;
gap: 6px;
flex-shrink: 0;
}
.thinking-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.thinking-dot.blue {
background: #3b82f6;
box-shadow: 0 0 8px #3b82f6;
animation: pulse-dot-blue 1.2s ease-in-out infinite;
}
.thinking-dot.violet {
background: #8b7cf6;
box-shadow: 0 0 8px #8b7cf6;
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
}
@keyframes pulse-dot-blue {
0%, 100% { opacity: 0.4; transform: scale(0.7); }
50% { opacity: 1; transform: scale(1.3); }
}
@keyframes pulse-dot-violet {
0%, 100% { opacity: 0.3; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1.4); }
}
.thinking-stream {
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
z-index: 1;
}
.thinking-entry {
display: flex;
gap: 10px;
align-items: baseline;
animation: slide-in-right 0.3s ease-out both;
font-size: 10px;
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
.entry-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 42px;
}
.entry-text {
color: #9ea5b3;
line-height: 1.4;
}
.thinking-placeholder {
font-size: 10px;
color: #4a5160;
font-style: italic;
}
/* Working Feed */
.work-feed {
display: flex;
flex-direction: column;
gap: 6px;
}
.work-step {
display: flex;
align-items: center;
gap: 8px;
}
.step-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-color);
flex-shrink: 0;
opacity: 0.5;
}
.step-text {
font-size: 10.5px;
color: #7e8799;
line-height: 1.35;
}
.step-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 36px;
}
/* Footer */
.modal-footer {
display: flex;
gap: 10px;
padding-top: 14px;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.footer-badge {
font-size: 9px;
font-weight: 600;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: #7e8799;
}
</style>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Code2, Server, Search, Shield, Bot } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
const props = defineProps<{
agent: AgentNodeData
runtime: string
}>()
const emit = defineEmits<{
select: [agentId: string]
}>()
const iconComponent = computed(() => {
switch (props.agent.icon) {
case 'code': return Code2
case 'server': return Server
case 'search': return Search
case 'shield': return Shield
default: return Bot
}
})
/* Workload Ring */
const R = 18
const STROKE = 3
const CIRCUMFERENCE = 2 * Math.PI * R
const workloadOffset = computed(() => {
const pct = Math.min(props.agent.workload, 100)
return CIRCUMFERENCE - (CIRCUMFERENCE * pct) / 100
})
const workloadRingColor = computed(() => {
const w = props.agent.workload
if (w < 40) return '#22c55e'
if (w < 65) return '#eab308'
if (w < 85) return '#f97316'
return '#ef4444'
})
const cardClass = computed(() =>
props.agent.active ? 'agent-card pulse-active' : 'agent-card node-idle'
)
</script>
<template>
<article
:class="cardClass"
:style="{ '--agent-color': agent.color }"
@click="emit('select', agent.id)"
tabindex="0"
@keyup.enter="emit('select', agent.id)"
role="button"
>
<!-- Workload Ring -->
<svg class="wl-ring" viewBox="0 0 44 44" width="44" height="44">
<circle
cx="22" cy="22" :r="R"
fill="none"
stroke="rgba(255,255,255,0.05)"
:stroke-width="STROKE"
/>
<circle
cx="22" cy="22" :r="R"
fill="none"
:stroke="workloadRingColor"
:stroke-width="STROKE"
stroke-linecap="round"
:stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="workloadOffset"
transform="rotate(-90 22 22)"
class="ring-fill"
/>
</svg>
<!-- Icon -->
<div class="node-icon" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="iconComponent" :size="18" />
</div>
<!-- Info -->
<div class="node-info">
<div class="node-name-row">
<h3 class="node-name">{{ agent.name }}</h3>
<span
class="node-status-dot"
:class="{ active: agent.active }"
:style="{ background: agent.active ? agent.color : '#6b7385' }"
></span>
</div>
<span class="node-role">{{ agent.role }}</span>
<span class="node-task" :title="agent.currentTask">{{ agent.currentTask }}</span>
</div>
<!-- Runtime -->
<div class="node-runtime">{{ runtime }}</div>
</article>
</template>
<style scoped>
.agent-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid color-mix(in srgb, var(--agent-color) 15%, transparent);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.agent-card:hover {
border-color: color-mix(in srgb, var(--agent-color) 40%, transparent);
box-shadow: 0 0 24px color-mix(in srgb, var(--agent-color) 8%, transparent);
transform: translateY(-1px);
}
.agent-card:focus-visible {
outline: 2px solid var(--agent-color);
outline-offset: 2px;
}
.node-idle {
opacity: 0.6;
}
/* Workload SVG Ring */
.wl-ring {
flex-shrink: 0;
}
.ring-fill {
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
}
/* Icon */
.node-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 10px;
flex-shrink: 0;
}
/* Info */
.node-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.node-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.node-name {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
}
.node-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.3s ease;
flex-shrink: 0;
}
.node-status-dot.active {
box-shadow: 0 0 6px currentColor;
}
.node-role {
font-size: 9px;
color: #6b7385;
font-weight: 500;
}
.node-task {
font-size: 9.5px;
color: #7e8799;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.node-runtime {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #6b7385;
flex-shrink: 0;
font-weight: 600;
min-width: 40px;
text-align: right;
}
/* Pulse active dot */
.pulse-active .node-status-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.4); }
}
</style>
@@ -0,0 +1,499 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { Bot, Send, LoaderCircle, Maximize2, X } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
const props = defineProps<{
messages: ChatMessage[]
irisBusy: boolean
irisFocus: string
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null)
const chatModalListRef = ref<HTMLElement | null>(null)
const chatModalOpen = ref(false)
function sendMessage(): void {
if (!inputText.value.trim()) return
emit('send', inputText.value)
inputText.value = ''
}
watch(
() => props.messages.length,
async () => {
await nextTick()
const el = chatModalOpen.value ? chatModalListRef.value : chatListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
)
</script>
<template>
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="chat-header-icon" />
<h2>Iris Chat</h2>
</div>
<div v-if="irisBusy" class="busy-badge">
<LoaderCircle :size="10" class="spin" />
<span>Busy</span>
</div>
<button class="chat-expand-btn" @click="chatModalOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</button>
</div>
<!-- Focus Bar -->
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<!-- Messages -->
<div ref="chatListRef" class="chat-messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
>
<div v-if="msg.sender === 'iris'" class="msg-avatar">
<Bot :size="12" />
</div>
<div class="msg-bubble">
<p>{{ msg.text }}</p>
</div>
</div>
<div v-if="messages.length === 0" class="empty-state">
<p>No messages yet. Start a conversation with Iris.</p>
</div>
</div>
<!-- Input -->
<div class="chat-input-row">
<input
v-model="inputText"
type="text"
placeholder="Type a message..."
@keyup.enter="sendMessage"
/>
<button
class="send-btn"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="14" />
</button>
</div>
</div>
<!-- Expanded Chat Modal -->
<Teleport to="body">
<div v-if="chatModalOpen" class="chat-modal-overlay" @click.self="chatModalOpen = false">
<div class="chat-modal">
<div class="chat-modal-header">
<div class="chat-modal-header-left">
<Bot :size="18" class="chat-header-icon" />
<h2>Iris Chat</h2>
</div>
<div v-if="irisBusy" class="busy-badge">
<LoaderCircle :size="10" class="spin" />
<span>Busy</span>
</div>
<button class="chat-modal-close" @click="chatModalOpen = false" title="Close">
<X :size="16" />
</button>
</div>
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<div ref="chatModalListRef" class="chat-modal-messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
>
<div v-if="msg.sender === 'iris'" class="msg-avatar">
<Bot :size="14" />
</div>
<div class="msg-bubble">
<p>{{ msg.text }}</p>
</div>
</div>
<div v-if="messages.length === 0" class="empty-state">
<p>No messages yet. Start a conversation with Iris.</p>
</div>
</div>
<div class="chat-modal-input-row">
<input
v-model="inputText"
type="text"
placeholder="Type a message..."
@keyup.enter="sendMessage"
/>
<button
class="send-btn"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="16" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.chat-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 360px;
max-height: 480px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
overflow: hidden;
}
.chat-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.chat-header-icon {
color: #a78bfa;
}
.chat-header h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
.busy-badge {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
color: #eab308;
padding: 3px 10px;
border-radius: 20px;
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(234, 179, 8, 0.15);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Expand Button */
.chat-expand-btn {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: #6b7385;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
margin-left: 6px;
}
.chat-expand-btn:hover {
background: rgba(167, 139, 250, 0.12);
border-color: rgba(167, 139, 250, 0.25);
color: #a78bfa;
}
/* Focus Bar */
.focus-bar {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 16px;
background: rgba(234, 179, 8, 0.04);
border-bottom: 1px solid rgba(234, 179, 8, 0.08);
}
.focus-label {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #eab308;
}
.focus-text {
font-size: 10px;
color: #7e8799;
line-height: 1.3;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.msg-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.msg-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.msg-avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
flex-shrink: 0;
align-self: flex-end;
}
.msg-bubble {
padding: 8px 12px;
border-radius: 10px;
font-size: 10.5px;
line-height: 1.45;
}
.msg-iris .msg-bubble {
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.1);
color: #d4d8e0;
}
.msg-user .msg-bubble {
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.15);
color: #e8eaf0;
}
.msg-bubble p {
margin: 0;
}
.empty-state {
flex: 1;
display: grid;
place-items: center;
text-align: center;
}
.empty-state p {
font-size: 10px;
color: #6b7385;
max-width: 180px;
line-height: 1.4;
}
/* Input */
.chat-input-row {
display: flex;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-input-row input {
flex: 1;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #e8eaf0;
font-size: 10px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
min-width: 0;
}
.chat-input-row input:focus {
border-color: #a78bfa;
}
.chat-input-row input::placeholder {
color: #6b7385;
}
.send-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: #a78bfa;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.2s;
}
.send-btn:disabled {
opacity: 0.35;
cursor: default;
}
.send-btn:not(:disabled):hover {
opacity: 0.85;
}
/* Chat Modal Overlay */
.chat-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.chat-modal {
display: flex;
flex-direction: column;
width: 90%;
max-width: 700px;
height: 70vh;
max-height: 80vh;
background: rgba(22, 27, 34, 0.92);
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: modal-in 0.2s ease-out;
}
@keyframes modal-in {
from { opacity: 0; transform: scale(0.95) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.chat-modal-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-modal-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.chat-modal-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.chat-modal-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.chat-modal-close:hover {
background: rgba(255, 255, 255, 0.08);
color: #e8eaf0;
}
.chat-modal-messages {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-modal-messages::-webkit-scrollbar {
width: 6px;
}
.chat-modal-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-modal-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.chat-modal-input-row {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-modal-input-row input {
flex: 1;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #e8eaf0;
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
min-width: 0;
}
.chat-modal-input-row input:focus {
border-color: #a78bfa;
}
.chat-modal-input-row input::placeholder {
color: #6b7385;
}
</style>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
const props = defineProps<{
entries: FeedEntry[]
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const selectedDayOffset = ref(0) // 0 = today, -1 = yesterday, etc.
function close() {
emit('update:modelValue', false)
}
function dayLabel(offset: number): string {
if (offset === 0) return 'Heute'
if (offset === -1) return 'Gestern'
if (offset === -2) return 'Vorgestern'
const d = new Date()
d.setDate(d.getDate() + offset)
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function navigateDay(dir: -1 | 1) {
const next = selectedDayOffset.value + dir
if (next >= -6 && next <= 0) {
selectedDayOffset.value = next
}
}
const filteredEntries = computed(() => {
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + selectedDayOffset.value)
const targetStr = targetDate.toISOString().slice(0, 10)
return props.entries.filter(e => e.timestamp.slice(0, 10) === targetStr)
})
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
<div class="feed-modal-card">
<div class="feed-modal-header">
<h2 class="feed-modal-title">Operations Log</h2>
<button class="feed-modal-close-btn" @click="close" aria-label="Close">
<X :size="16" />
</button>
</div>
<div class="feed-modal-nav">
<button
class="feed-nav-btn"
:disabled="selectedDayOffset <= -6"
@click="navigateDay(-1)"
aria-label="Previous day"
>
<ChevronLeft :size="14" />
</button>
<span class="feed-nav-label">{{ dayLabel(selectedDayOffset) }}</span>
<button
class="feed-nav-btn"
:disabled="selectedDayOffset >= 0"
@click="navigateDay(1)"
aria-label="Next day"
>
<ChevronRight :size="14" />
</button>
</div>
<div class="feed-modal-entries">
<div v-if="filteredEntries.length === 0" class="feed-modal-empty">
Keine Einträge für diesen Tag.
</div>
<div
v-for="(entry, idx) in filteredEntries"
:key="entry.timestamp + '-' + idx"
class="feed-modal-entry"
>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.feed-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
padding: 20px;
animation: feed-overlay-in 0.2s ease;
}
@keyframes feed-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.feed-modal-card {
background: #161b22;
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
animation: feed-card-in 0.25s ease;
}
@keyframes feed-card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.feed-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.feed-modal-close-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
color: #7e8799;
cursor: pointer;
transition: all 0.15s;
}
.feed-modal-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e8eaf0;
}
.feed-modal-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.feed-nav-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid rgba(139, 124, 246, 0.15);
background: rgba(139, 124, 246, 0.08);
border-radius: 8px;
color: #a78bfa;
cursor: pointer;
transition: all 0.15s;
}
.feed-nav-btn:hover:not(:disabled) {
background: rgba(139, 124, 246, 0.16);
border-color: rgba(139, 124, 246, 0.3);
}
.feed-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.feed-nav-label {
font-size: 12px;
font-weight: 600;
color: #d1d5db;
min-width: 100px;
text-align: center;
}
.feed-modal-entries {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 50vh;
padding-right: 4px;
}
.feed-modal-empty {
text-align: center;
padding: 24px 0;
font-size: 11px;
color: #6b7385;
}
.feed-modal-entry {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-modal-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
}
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
</style>
@@ -1,99 +1,61 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref, computed } from 'vue'
import { Activity } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
import FeedDetailModal from './FeedDetailModal.vue'
type FeedStatus = 'running' | 'done' | 'waiting' | 'error' | 'info'
const props = defineProps<{
entries: FeedEntry[]
}>()
interface FeedItem {
time: string
status: FeedStatus
text: string
label: string
date: 'today' | 'yesterday' | 'week'
}
// ── Compact feed (5 items) ──
const compactEntries = computed(() => props.entries.slice(0, 5))
const allFeed: FeedItem[] = [
{ time: '09:17', status: 'running', text: 'OpenClaw analysiert Memory-Datenbank.', label: 'Memory', date: 'today' },
{ time: '09:19', status: 'done', text: 'Repository Refactoring abgeschlossen.', label: 'Coding', date: 'today' },
{ time: '09:21', status: 'done', text: '3 neue Erinnerungen gespeichert.', label: 'Memory', date: 'today' },
{ time: '09:25', status: 'done', text: 'Dungeon-Service erfolgreich kompiliert.', label: 'Coding', date: 'today' },
{ time: '09:28', status: 'error', text: 'Build fehlgeschlagen — NullReferenceException in EnemyFactory.', label: 'Coding', date: 'today' },
{ time: '09:31', status: 'waiting', text: 'Iris hat "Steuerunterlagen" auf Freitag verschoben.', label: 'Personal', date: 'today' },
{ time: '10:02', status: 'running', text: 'Programmer arbeitet an TeamView-Redesign.', label: 'Coding', date: 'today' },
{ time: '10:15', status: 'done', text: 'AgentDetailView deployed.', label: 'System', date: 'today' },
{ time: '10:22', status: 'running', text: 'Architekt prüft Compose-Konfiguration.', label: 'System', date: 'today' },
{ time: '10:45', status: 'done', text: 'Reviewer: Code-Review abgeschlossen, keine Findings.', label: 'Agenten', date: 'today' },
{ time: '11:00', status: 'running', text: 'Researcher analysiert API-Dokumentation.', label: 'Research', date: 'today' },
{ time: '11:30', status: 'waiting', text: 'Executor wartet auf Deployment-Freigabe.', label: 'System', date: 'today' },
{ time: '15:22', status: 'done', text: 'Nexus Dashboard Migration geplant.', label: 'Coding', date: 'yesterday' },
{ time: '16:05', status: 'done', text: 'Docker Compose Optimierung abgeschlossen.', label: 'System', date: 'yesterday' },
]
// ── Feed Detail Modal ──
const showDetailModal = ref(false)
const feedFilter = ref<string | null>(null)
const filterLabels = ['Alle', 'Coding', 'Research', 'Personal', 'Memory', 'Agenten', 'System']
const filteredFeed = computed(() => {
if (!feedFilter.value || feedFilter.value === 'Alle') return allFeed
return allFeed.filter(item => item.label === feedFilter.value)
})
const feedGroups = computed(() => {
const groups: { date: string; items: FeedItem[] }[] = []
const dates = ['today', 'yesterday', 'week'] as const
for (const d of dates) {
const items = filteredFeed.value.filter(i => i.date === d)
if (items.length) {
groups.push({
date: d === 'today' ? 'Heute' : d === 'yesterday' ? 'Gestern' : 'Diese Woche',
items,
})
}
}
return groups
})
const statusColor = (s: FeedStatus): string => {
const m: Record<FeedStatus, string> = {
running: '#3b82f6',
done: '#22c55e',
waiting: '#eab308',
error: '#ef4444',
info: '#6b7385',
}
return m[s]
function openDetailModal() {
showDetailModal.value = true
}
</script>
<template>
<div class="feed-panel">
<h2 class="feed-title">Operations Feed</h2>
<div class="filter-pills">
<button
v-for="label in filterLabels"
:key="label"
:class="{ active: feedFilter === label || (!feedFilter && label === 'Alle') }"
@click="feedFilter = label === 'Alle' ? null : label"
>
{{ label }}
</button>
<div class="feed-header">
<Activity :size="14" class="feed-icon" />
<h2>Operations Feed</h2>
</div>
<div class="feed-list">
<template v-for="group in feedGroups" :key="group.date">
<div class="feed-date-heading">{{ group.date }}</div>
<TransitionGroup name="feed-item" tag="div" class="feed-group-items">
<TransitionGroup name="feed">
<div
v-for="(item, idx) in group.items"
:key="group.date + '-' + idx"
class="feed-item"
v-for="(entry, idx) in compactEntries"
:key="entry.timestamp + '-' + idx"
class="feed-entry"
>
<span class="feed-time">{{ item.time }}</span>
<span class="feed-dot" :style="{ background: statusColor(item.status) }"></span>
<span class="feed-text">{{ item.text }}</span>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</TransitionGroup>
</template>
<div v-if="entries.length === 0" class="feed-empty">
<span>No operations recorded yet.</span>
</div>
<button v-if="entries.length > 5" class="feed-more-btn" @click="openDetailModal">
Mehr anzeigen
</button>
</div>
<FeedDetailModal
:entries="entries"
:model-value="showDetailModal"
@update:model-value="showDetailModal = $event"
/>
</div>
</template>
@@ -101,134 +63,134 @@ const statusColor = (s: FeedStatus): string => {
.feed-panel {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 420px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.feed-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
border-color: rgba(139, 124, 246, 0.15);
}
.feed-title {
font-size: 14px;
font-weight: 600;
.feed-header {
display: flex;
align-items: center;
gap: 6px;
}
.feed-icon {
color: #a78bfa;
}
.feed-header h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
/* Filter pills */
.filter-pills {
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 2px;
}
.filter-pills::-webkit-scrollbar {
display: none;
}
.filter-pills button {
flex-shrink: 0;
padding: 4px 10px;
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 20px;
background: transparent;
color: #6b7385;
font-size: 9px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.filter-pills button:hover {
border-color: rgba(139, 124, 246, 0.25);
color: #7e8799;
}
.filter-pills button.active {
background: rgba(139, 124, 246, 0.12);
border-color: rgba(139, 124, 246, 0.25);
color: #a78bfa;
}
/* Feed list */
.feed-list {
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
flex: 1;
position: relative;
}
.feed-group-items {
display: contents;
}
.feed-date-heading {
font-size: 9px;
font-weight: 700;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 8px 0 4px;
}
.feed-item {
.feed-entry {
display: flex;
align-items: center;
gap: 8px;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-item:hover {
background: rgba(139, 124, 246, 0.04);
.feed-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
font-size: 9px;
color: #6b7385;
flex-shrink: 0;
width: 36px;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-dot {
width: 7px;
height: 7px;
border-radius: 50%;
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
box-shadow: 0 0 4px currentColor;
}
.feed-text {
font-size: 10.5px;
line-height: 1.3;
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
/* TransitionGroup animations */
.feed-item-enter-active {
transition: all 0.3s ease;
}
.feed-item-leave-active {
transition: all 0.3s ease;
}
.feed-item-enter-from {
opacity: 0;
transform: translateX(-12px);
}
.feed-item-leave-to {
opacity: 0;
transform: translateX(12px);
}
.feed-item-move {
transition: all 0.3s ease;
.feed-empty {
text-align: center;
padding: 12px 0;
font-size: 10px;
color: #6b7385;
}
@media (max-width: 900px) {
.feed-panel {
order: 2;
.feed-more-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
color: #a78bfa;
font-size: 9.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.feed-more-btn:hover {
background: rgba(139, 124, 246, 0.14);
border-color: rgba(139, 124, 246, 0.2);
}
/* TransitionGroup */
.feed-enter-active {
transition: all 0.3s ease;
}
.feed-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.feed-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.feed-leave-to {
opacity: 0;
transform: translateX(10px);
}
.feed-move {
transition: transform 0.3s ease;
}
</style>
@@ -0,0 +1,344 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ListTodo,
ChevronUp,
ChevronDown,
ArrowUp,
ArrowDown,
Trash2,
Zap,
} from '@lucide/vue'
import type { QueueItem } from '../../composables/useDashboardData'
const props = defineProps<{
items: QueueItem[]
}>()
const emit = defineEmits<{
remove: [id: string]
moveUp: [id: string]
moveDown: [id: string]
changePriority: [id: string, priority: QueueItem['priority']]
executeNow: [id: string]
}>()
const expanded = ref(true)
const priorityColor: Record<string, string> = {
high: '#ef4444',
medium: '#eab308',
low: '#6b7385',
}
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(idx: number): void {
dragIndex.value = idx
}
function onDragOver(e: DragEvent, idx: number): void {
e.preventDefault()
dragOverIndex.value = idx
}
function onDrop(): void {
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
const id = props.items[dragIndex.value]?.id
if (id) {
const targetId = props.items[dragOverIndex.value]?.id
if (targetId) {
if (dragIndex.value < dragOverIndex.value) {
for (let i = dragIndex.value; i < dragOverIndex.value; i++) {
emit('moveDown', props.items[i]!.id)
}
} else {
for (let i = dragIndex.value; i > dragOverIndex.value; i--) {
emit('moveUp', props.items[i]!.id)
}
}
}
}
}
dragIndex.value = null
dragOverIndex.value = null
}
function onDragEnd(): void {
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="queue-panel">
<div class="queue-header" @click="expanded = !expanded">
<div class="queue-header-left">
<ListTodo :size="14" class="queue-icon" />
<h2>Chat Queue</h2>
<span class="queue-count">{{ items.length }}</span>
</div>
<button class="queue-toggle" aria-label="Toggle">
<ChevronUp v-if="expanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
</div>
<Transition name="queue-expand">
<div v-if="expanded" class="queue-list">
<div
v-for="(item, idx) in items"
:key="item.id"
class="queue-item"
:class="{
'drag-source': dragIndex === idx,
'drag-over': dragOverIndex === idx && dragIndex !== idx,
}"
draggable="true"
@dragstart="onDragStart(idx)"
@dragover="onDragOver($event, idx)"
@drop="onDrop"
@dragend="onDragEnd"
>
<div class="queue-item-body">
<div class="queue-item-head">
<span
class="priority-badge"
:style="{
color: priorityColor[item.priority],
borderColor: `${priorityColor[item.priority]}30`,
background: `${priorityColor[item.priority]}10`,
}"
>
{{ item.priority }}
</span>
<span class="queue-wait">{{ item.waitTime }}</span>
</div>
<p class="queue-text">{{ item.text }}</p>
</div>
<div class="queue-actions">
<button
class="q-action-btn"
title="Execute now"
@click.stop="emit('executeNow', item.id)"
>
<Zap :size="12" />
</button>
<button
class="q-action-btn"
title="Move up"
:disabled="idx === 0"
@click.stop="emit('moveUp', item.id)"
>
<ArrowUp :size="12" />
</button>
<button
class="q-action-btn"
title="Move down"
:disabled="idx === items.length - 1"
@click.stop="emit('moveDown', item.id)"
>
<ArrowDown :size="12" />
</button>
<button
class="q-action-btn q-action-danger"
title="Remove"
@click.stop="emit('remove', item.id)"
>
<Trash2 :size="12" />
</button>
</div>
</div>
<div v-if="items.length === 0" class="queue-empty">
<p>Queue is empty</p>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.queue-panel {
display: flex;
flex-direction: column;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
}
.queue-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
user-select: none;
}
.queue-header-left {
display: flex;
align-items: center;
gap: 7px;
}
.queue-icon {
color: #a78bfa;
}
.queue-header h2 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
}
.queue-count {
font-size: 10px;
font-weight: 700;
color: #a78bfa;
padding: 1px 7px;
border-radius: 10px;
background: rgba(167, 139, 250, 0.1);
font-variant-numeric: tabular-nums;
}
.queue-toggle {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
}
.queue-toggle:hover {
background: rgba(255, 255, 255, 0.04);
color: #e8eaf0;
}
.queue-list {
display: flex;
flex-direction: column;
padding: 0 10px 10px;
gap: 4px;
}
.queue-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
}
.queue-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.queue-item:active {
cursor: grabbing;
}
.drag-source {
opacity: 0.4;
}
.drag-over {
background: rgba(167, 139, 250, 0.08);
}
.queue-item-body {
flex: 1;
min-width: 0;
}
.queue-item-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.priority-badge {
font-size: 7px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid;
}
.queue-wait {
font-size: 8px;
color: #6b7385;
font-variant-numeric: tabular-nums;
}
.queue-text {
margin: 0;
font-size: 9.5px;
color: #7e8799;
line-height: 1.3;
}
/* Actions */
.queue-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
margin-top: 2px;
}
.queue-item:hover .queue-actions {
opacity: 1;
}
.q-action-btn {
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: none;
border-radius: 4px;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.15s;
}
.q-action-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
}
.q-action-btn:disabled {
opacity: 0.25;
cursor: default;
}
.q-action-danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.queue-empty {
text-align: center;
padding: 16px 0;
}
.queue-empty p {
margin: 0;
font-size: 10px;
color: #6b7385;
}
/* Transition */
.queue-expand-enter-active,
.queue-expand-leave-active {
transition: all 0.2s ease;
}
.queue-expand-enter-from,
.queue-expand-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Circle, ChevronRight } from '@lucide/vue'
import type { OpenTask } from '../../composables/useDashboardData'
defineProps<{
tasks: OpenTask[]
}>()
const emit = defineEmits<{
newTask: []
'go-board': []
}>()
const expandedId = ref<string | null>(null)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
</script>
<template>
<div class="task-card-panel">
<div class="task-header">
<h2 class="task-title">Offene Aufgaben</h2>
<button class="new-task-btn" @click="emit('newTask')">
<Plus :size="12" />
<span>New Task</span>
</button>
</div>
<div class="task-list">
<div v-if="tasks.length === 0" class="task-empty">
Keine offenen Aufgaben. Erstelle eine mit + New Task.
</div>
<TransitionGroup name="task">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ expanded: expandedId === task.id }"
@click="toggleExpand(task.id)"
>
<div class="task-main">
<Circle
:size="8"
class="task-source-dot"
:class="task.source === 'iris' ? 'dot-iris' : 'dot-bao'"
fill="currentColor"
/>
<div class="task-content">
<div class="task-title-row">
<span class="task-name">{{ task.title }}</span>
<span class="task-time">{{ task.createdAt }}</span>
</div>
<span
class="task-source-tag"
:class="task.source === 'iris' ? 'tag-iris' : 'tag-bao'"
>
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
</span>
</div>
</div>
<div v-if="expandedId === task.id" class="task-detail">
{{ task.detail }}
</div>
</div>
</TransitionGroup>
</div>
<button class="task-board-btn" @click="emit('go-board')">
<span>Zum Task Board</span>
<ChevronRight :size="14" />
</button>
</div>
</template>
<style scoped>
.task-card-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.task-card-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-title {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.new-task-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(139, 124, 246, 0.12);
border: 1px solid rgba(139, 124, 246, 0.2);
border-radius: 6px;
color: #a78bfa;
font-size: 9px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.new-task-btn:hover {
background: rgba(139, 124, 246, 0.2);
border-color: rgba(139, 124, 246, 0.35);
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.task-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(139, 124, 246, 0.08);
}
.task-item.expanded {
background: rgba(139, 124, 246, 0.04);
border-color: rgba(139, 124, 246, 0.1);
}
.task-main {
display: flex;
align-items: flex-start;
gap: 8px;
}
.task-source-dot {
margin-top: 4px;
flex-shrink: 0;
}
.dot-iris {
color: #a78bfa;
}
.dot-bao {
color: #3b82f6;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-name {
font-size: 10px;
font-weight: 500;
color: #d1d5db;
line-height: 1.35;
}
.task-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.task-source-tag {
display: inline-block;
font-size: 8px;
font-weight: 600;
padding: 1px 7px;
border-radius: 4px;
letter-spacing: 0.02em;
align-self: flex-start;
line-height: 1.4;
}
.tag-iris {
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.tag-bao {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.task-detail {
padding: 6px 10px;
margin: 0 0 2px 16px;
font-size: 9.5px;
color: #7e8799;
line-height: 1.45;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
border-left: 2px solid rgba(139, 124, 246, 0.2);
}
.task-empty {
text-align: center;
padding: 16px 8px;
font-size: 10px;
color: #6b7385;
line-height: 1.5;
}
.task-board-btn {
width: 100%; margin-top: 12px; padding: 10px;
display: flex; align-items: center; justify-content: center; gap: 6px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 10px; color: #a78bfa;
font-size: 10px; font-weight: 600; cursor: pointer;
transition: all 0.2s;
}
.task-board-btn:hover {
background: rgba(139, 124, 246, 0.15);
border-color: rgba(139, 124, 246, 0.3);
}
/* TransitionGroup */
.task-enter-active {
transition: all 0.3s ease;
}
.task-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.task-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.task-leave-to {
opacity: 0;
transform: translateY(6px);
}
.task-move {
transition: transform 0.3s ease;
}
</style>
@@ -0,0 +1,439 @@
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
const props = defineProps<{
agents: AgentNodeData[]
heroId?: string
activeAgents?: string[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// ── Network ref ──
const networkRef = ref<HTMLDivElement | null>(null)
// ── Computed data ──
const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── SVG composable ──
const {
svgWidth,
svgHeight,
childAgents,
connectionPaths,
storePathRef,
storePulseRef,
storePulseRef2,
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
// ── Icon resolver ──
function resolveIcon(iconName: string) {
switch (iconName) {
case 'bot': return Bot
case 'code': return Code2
case 'server': return Server
case 'shield': return Shield
case 'search': return Search
case 'terminal': return Terminal
default: return Bot
}
}
// ── Runtime formatter ──
function formatRuntime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ── Hero computed ──
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
</script>
<template>
<div ref="networkRef" class="ai-team-network">
<!-- SVG Connection Layer -->
<svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg"
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
v-for="agent in childAgents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Connection lines for each agent -->
<template v-for="agent in childAgents" :key="agent.id">
<!-- Base line -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePathRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
fill="none"
:opacity="isActive(agent.id) ? 0.7 : 0.25"
stroke-linecap="round"
/>
<!-- Glow line for active agent -->
<path
v-if="isActive(agent.id) && connectionPaths[agent.id]"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/>
<!-- Pulse line 1 (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
<!-- Pulse line 2 (offset by half cycle) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef2(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 0.8 : 0.3"
/>
</template>
</svg>
<!-- Cards Layer (above SVG) -->
<div class="cards-layer">
<!-- Hero: Iris centered top -->
<div class="hero-slot" :data-agent-id="hero.id">
<article
class="agent-card hero-card"
:style="{
'--card-color': hero.color,
...(isActive(hero.id) ? {
boxShadow: `0 0 20px ${hero.color}44`,
borderColor: hero.color
} : {})
}"
@click="emit('select', hero.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${hero.color}18`, color: hero.color }">
<component :is="resolveIcon(hero.icon)" :size="20" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ hero.name }}</h3>
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
</div>
<p class="card-desc">{{ hero.description }}</p>
<div v-if="hero.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot"></span>
{{ hero.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
<span v-if="hero.model" class="node-model">{{ hero.model }}</span>
</div>
<div class="card-tags">
<span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
</div>
</div>
</div>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
<!-- Agent Grid: 2 columns x 2 rows -->
<div class="agent-grid">
<div
v-for="agent in childAgents"
:key="agent.id"
:data-agent-id="agent.id"
class="agent-slot"
>
<article
class="agent-card"
:style="{
'--card-color': agent.color,
...(isActive(agent.id) ? {
boxShadow: `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`,
borderColor: agent.color
} : {})
}"
@click="emit('select', agent.id)"
>
<div class="card-main">
<div class="card-icon-wrap" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="resolveIcon(agent.icon)" :size="18" />
</div>
<div class="card-body">
<div class="card-name-row">
<h3 class="card-name">{{ agent.name }}</h3>
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
</div>
<p class="card-desc">{{ agent.description }}</p>
<div v-if="agent.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot"></span>
{{ agent.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
<span v-if="agent.model" class="node-model">{{ agent.model }}</span>
</div>
<div class="card-tags">
<span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
</div>
</div>
</div>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ai-team-network {
position: relative;
width: 100%;
background: transparent;
}
.network-svg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.cards-layer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 64px;
}
.hero-slot {
width: 100%;
max-width: 520px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.agent-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 820px;
}
.agent-slot {
width: 100%;
transition: border-color 0.3s, box-shadow 0.3s;
}
/* ── Agent Card ── */
.agent-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
position: relative;
}
.agent-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: var(--card-color, #8b7cf6);
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
}
.hero-card {
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
}
.hero-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: #8b7cf6;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
}
.card-main {
display: flex;
gap: 14px;
align-items: flex-start;
}
.card-icon-wrap {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 10px;
flex-shrink: 0;
}
.card-body {
flex: 1;
min-width: 0;
}
.card-name-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
flex-wrap: wrap;
}
.card-name {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e8eaf0;
}
.card-role-tag {
display: inline-block;
font-size: 8.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
border: 1px solid transparent;
white-space: nowrap;
}
.card-desc {
font-size: 10.5px;
color: #7e8799;
line-height: 1.5;
margin: 0 0 8px;
}
/* ── Task + Runtime Row ── */
.task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.node-task {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #9ea5b3;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.node-model {
font-size: 8.5px;
color: #6b7385;
font-weight: 500;
flex-shrink: 0;
margin-left: 6px;
}
/* ── Tags ── */
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.card-tag {
display: inline-block;
font-size: 9px;
font-weight: 600;
padding: 2px 8px;
border-radius: 5px;
letter-spacing: 0.02em;
}
/* ── Hover Arrow ── */
.card-arrow {
position: absolute;
right: 12px;
bottom: 12px;
color: #6b7385;
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.agent-card:hover .card-arrow {
opacity: 1;
transform: translateX(0);
}
.arrow-icon {
font-size: 14px;
line-height: 1;
display: block;
}
@media (max-width: 720px) {
.agent-grid {
grid-template-columns: 1fr;
}
.cards-layer {
gap: 20px;
}
}
</style>
@@ -3,7 +3,7 @@ import { computed } from 'vue'
import {
Activity, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, Users, BookOpen,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
@@ -29,7 +29,6 @@ const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Team', icon: Users },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import AgentCard from './AgentCard.vue'
interface AgentData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
hero?: boolean
}
const props = defineProps<{
agents: AgentData[]
heroId?: string
activeAgents?: string[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// ── Layout refs ──
const networkRef = ref<HTMLDivElement | null>(null)
interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
// ── Computed data ──
const hero = computed(() => props.agents.find(a => a.id === props.heroId) ?? props.agents[0])
const childAgents = computed(() => props.agents.filter(a => a.id !== props.heroId))
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── Position measurement ──
function updatePositions() {
if (!networkRef.value) return
const rect = networkRef.value.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(el => {
const id = el.getAttribute('data-agent-id')
if (!id) return
const r = el.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
}
// ── SVG path computation ──
interface ConnectionPath {
d: string
length: number
}
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const heroEntry = props.agents.find(a => a.id === props.heroId)
const heroId = heroEntry?.id ?? ''
const iris = heroId ? pos[heroId] : undefined
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.30 + t * 0.40)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 40
const cp2x = endX + (isLeftColumn ? 50 : -50)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
let animFrameId: number | null = null
let lastAnimTime = 0
// Track path elements and animation offset per agent
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
/** Refresh path lengths and pulse dasharrays from current SVG elements */
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `10 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
}
}
function startPulseAnimation() {
const speeds: Record<string, number> = {}
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000 // full traversal in ~3s
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + (speeds[id] ?? len / 3000) * dt
const cycleLen = len + 10
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
// Paths changed — recalculate lengths and dasharrays
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
</script>
<template>
<div ref="networkRef" class="team-network">
<!-- SVG Connection Layer -->
<svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg"
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
v-for="agent in childAgents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Connection lines for each agent -->
<template v-for="agent in childAgents" :key="agent.id">
<!-- Base line -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePathRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
fill="none"
:opacity="isActive(agent.id) ? 0.7 : 0.25"
stroke-linecap="round"
/>
<!-- Glow line for active agent -->
<path
v-if="isActive(agent.id) && connectionPaths[agent.id]"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/>
<!-- Pulse line (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
</template>
</svg>
<!-- Cards Layer (above SVG) -->
<div class="cards-layer">
<!-- Hero: Iris centered top -->
<div class="hero-slot" data-agent-id="iris">
<AgentCard
v-bind="hero"
:class="{ 'hero-active': isActive(hero.id) }"
:style="{
boxShadow: isActive(hero.id)
? `0 0 20px ${hero.color}44`
: undefined,
borderColor: isActive(hero.id) ? hero.color : undefined,
}"
@click="emit('select', hero.id)"
/>
</div>
<!-- Agent Grid: 2 columns x 2 rows -->
<div class="agent-grid">
<div
v-for="agent in childAgents"
:key="agent.id"
:data-agent-id="agent.id"
class="agent-slot"
>
<AgentCard
v-bind="agent"
:style="{
boxShadow: isActive(agent.id)
? `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`
: undefined,
borderColor: isActive(agent.id) ? agent.color : undefined,
}"
@click="emit('select', agent.id)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.team-network {
position: relative;
width: 100%;
}
.network-svg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.cards-layer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.hero-slot {
width: 100%;
max-width: 520px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.hero-active {
transition: border-color 0.3s, box-shadow 0.3s;
}
.agent-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 820px;
}
.agent-slot {
width: 100%;
transition: border-color 0.3s, box-shadow 0.3s;
}
@media (max-width: 720px) {
.agent-grid {
grid-template-columns: 1fr;
}
.cards-layer {
gap: 20px;
}
}
</style>
@@ -0,0 +1,324 @@
import { ref, reactive, computed } from 'vue'
export interface AgentNodeData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
model?: string
hero?: boolean
currentTask: string
goal: string
progress: number
workload: number // 0-100
active: boolean
runtimeSeconds: number
workingFeed: Array<{ time: string; text: string }>
thinkingStream?: Array<{ time: string; text: string }>
}
export interface OpenTask {
id: string
title: string
detail: string
source: 'bao' | 'iris'
createdAt: string
}
export interface FeedEntry {
time: string
agent: string
action: string
timestamp: string
}
export interface ChatMessage {
id: string
sender: 'user' | 'iris'
text: string
timestamp: number
}
export interface QueueItem {
id: string
text: string
priority: 'high' | 'medium' | 'low'
waitTime: string
}
const now = Date.now()
export function useDashboardData() {
const sessionStart = Date.now()
// Runtime counter
const runtimeSeconds = ref(0)
let runtimeInterval: ReturnType<typeof setInterval> | null = null
function startRuntime() {
const startTs = sessionStart
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
runtimeInterval = setInterval(() => {
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
}, 1000)
}
function stopRuntime() {
if (runtimeInterval) {
clearInterval(runtimeInterval)
runtimeInterval = null
}
}
const formatRuntime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const irisRuntime = computed(() => formatRuntime(runtimeSeconds.value))
// Agent runtimes (simulated)
const agentStartTimes = reactive<Record<string, number>>({
iris: now - 28800000,
developer: now - 3600000,
devops: now - 1800000,
researcher: now - 2700000,
reviewer: now - 900000,
})
const getAgentRuntime = (id: string): string => {
const start = agentStartTimes[id]
if (!start) return '00:00'
const secs = Math.floor((now - start) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// Agents
const agents = ref<AgentNodeData[]>([
{
id: 'iris',
name: 'Iris',
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
color: '#8b7cf6',
icon: 'bot',
hero: true,
currentTask: 'Orchestrating Nexus Dashboard redesign',
goal: 'Complete Mission Control v3',
progress: 85,
workload: 55,
active: true,
runtimeSeconds: 28800,
model: 'GPT-5.4',
workingFeed: [
{ time: '22:38', text: 'Analyzed user feedback on Dashboard' },
{ time: '22:36', text: 'Delegated card redesign to Developer' },
{ time: '22:34', text: 'Verifying full-width layout deployment' },
{ time: '22:32', text: 'Reviewing AgentModal integration' },
],
thinkingStream: [
{ time: '22:24', text: 'Analysing constraint: full-width layout' },
{ time: '22:25', text: 'Removing max-width from global CSS' },
{ time: '22:26', text: 'Verifying Dashboard grid reflow' },
],
},
{
id: 'developer',
name: 'Developer',
role: 'Backend & Frontend',
description: 'Implements features across the stack with TypeScript, C#, and Vue.',
tags: ['Coding', 'Development', 'Builds'],
color: '#3b82f6',
icon: 'code',
currentTask: 'Building Dungeon System API endpoints',
goal: 'Complete Dungeon CRUD + room generation',
progress: 62,
workload: 65,
active: true,
runtimeSeconds: 3600,
workingFeed: [
{ time: '22:30', text: 'Created DungeonController' },
{ time: '22:28', text: 'Defined dungeon schema' },
{ time: '22:26', text: 'Implementing room generation algorithm' },
{ time: '22:24', text: 'Writing unit tests for RoomFactory' },
],
thinkingStream: [
{ time: '22:22', text: 'Parsing dungeon spec from Iris' },
{ time: '22:23', text: 'Designing RoomFactory interface' },
{ time: '22:24', text: 'Implementing corridor connection logic' },
],
model: 'DeepSeek V4 Flash',
},
{
id: 'devops',
name: 'DevOps',
role: 'Infrastructure & CI/CD',
description: 'Manages Docker, deployment pipelines, and system reliability.',
tags: ['Deployment', 'Docker', 'CI/CD'],
color: '#eab308',
icon: 'server',
currentTask: 'Optimizing Docker Compose caching',
goal: 'Reduce build times by 40%',
progress: 45,
workload: 40,
active: false,
runtimeSeconds: 1800,
workingFeed: [
{ time: '22:20', text: 'Analyzed Docker layer cache' },
{ time: '22:18', text: 'Optimized COPY order in Dockerfile' },
{ time: '22:16', text: 'Added .dockerignore for node_modules' },
{ time: '22:14', text: 'Testing incremental builds' },
],
thinkingStream: [
{ time: '22:20', text: 'Checking build cache hit rates' },
{ time: '22:21', text: 'Benchmarking multi-stage vs single-stage' },
{ time: '22:22', text: 'Calculating potential speedup from caching' },
],
model: 'DeepSeek V4 Pro',
},
{
id: 'researcher',
name: 'Researcher',
role: 'Analysis & Documentation',
description: 'Researches APIs, patterns, and best practices. Maintains docs.',
tags: ['Research', 'Analysis', 'Docs'],
color: '#22c55e',
icon: 'search',
currentTask: 'Analyzing WebSocket alternatives',
goal: 'Recommend real-time communication strategy',
progress: 30,
workload: 25,
active: true,
runtimeSeconds: 2700,
workingFeed: [
{ time: '22:18', text: 'Evaluated WebSocket vs SSE vs WebRTC' },
{ time: '22:17', text: 'Documented SignalR limitations' },
{ time: '22:16', text: 'Prototyping WebSocket fallback' },
],
thinkingStream: [
{ time: '22:18', text: 'Cross-referencing WebSocket latency benchmarks' },
{ time: '22:19', text: 'Checking SSE browser support matrix' },
{ time: '22:20', text: 'Drafting recommendation summary' },
],
model: 'DeepSeek V4 Pro',
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'Code Quality & Testing',
description: 'Reviews pull requests, enforces standards, runs test suites.',
tags: ['Code Review', 'Testing', 'Quality'],
color: '#a855f7',
icon: 'shield',
currentTask: 'Reviewing Dungeon System PR',
goal: 'Zero critical findings before merge',
progress: 80,
workload: 50,
active: false,
runtimeSeconds: 900,
workingFeed: [
{ time: '22:15', text: 'Reviewed DungeonController.cs' },
{ time: '22:14', text: 'Found 3 minor style issues' },
{ time: '22:13', text: 'Approved RoomValidator' },
{ time: '22:12', text: 'Running integration tests' },
],
thinkingStream: [
{ time: '22:15', text: 'Analyzing DungeonController PR diff' },
{ time: '22:16', text: 'Checking RoomValidator edge cases' },
{ time: '22:17', text: 'Verifying integration test coverage' },
],
model: 'DeepSeek V4 Pro',
},
])
// Open Tasks
const openTasks = ref<OpenTask[]>([
{ id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' },
{ id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' },
{ id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' },
])
// Feed
const ts = (offset: number) => new Date(now + offset).toISOString()
const feedEntries = ref<FeedEntry[]>([
{ time: '22:50', agent: 'Developer', action: 'Created DungeonController endpoints', timestamp: ts(-120000) },
{ time: '22:46', agent: 'DevOps', action: 'Optimized Docker COPY order for layer caching', timestamp: ts(-360000) },
{ time: '22:42', agent: 'Iris', action: 'Delegated room generation to Developer with spec', timestamp: ts(-600000) },
{ time: '22:35', agent: 'Researcher', action: 'Documented WebSocket vs SSE analysis results', timestamp: ts(-960000) },
{ time: '22:28', agent: 'Reviewer', action: 'Approved RoomValidator PR with minor fixes', timestamp: ts(-1200000) },
{ time: '22:18', agent: 'DevOps', action: 'Added .dockerignore for node_modules and build artifacts', timestamp: ts(-1500000) },
{ time: '22:08', agent: 'Iris', action: 'Broke down Dungeon System tasks into sub-tasks', timestamp: ts(-1800000) },
{ time: '21:55', agent: 'Developer', action: 'Defined dungeon schema models with validation', timestamp: ts(-2400000) },
])
// Chat
const chatMessages = ref<ChatMessage[]>([
{ id: 'm1', sender: 'iris', text: 'Guten Abend, Bao. Ready to continue the Dungeon System?', timestamp: now - 600000 },
{ id: 'm2', sender: 'user', text: "Yes, what's the status?", timestamp: now - 540000 },
{ id: 'm3', sender: 'iris', text: "Developer is at 62% on room generation. Reviewer approved the schema. I'd recommend focusing on the room connection logic next.", timestamp: now - 480000 },
])
const irisBusy = ref(true)
const irisFocus = ref('Breaking down Dungeon System for DevOps and Developer')
// Queue
const queue = ref<QueueItem[]>([
{ id: 'q1', text: 'Deploy latest dashboard build to preview', priority: 'high', waitTime: '2 min' },
{ id: 'q2', text: 'Review infrastructure cost analysis', priority: 'medium', waitTime: '8 min' },
{ id: 'q3', text: 'Schedule B2 German lesson review', priority: 'low', waitTime: '15 min' },
{ id: 'q4', text: 'Update project roadmap document', priority: 'medium', waitTime: '12 min' },
])
function sendChat(text: string): void {
if (!text.trim()) return
chatMessages.value.push({
id: `user-${Date.now()}`,
sender: 'user',
text: text.trim(),
timestamp: Date.now(),
})
}
function removeQueueItem(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx !== -1) queue.value.splice(idx, 1)
}
function moveQueueItem(fromIdx: number, toIdx: number): void {
if (toIdx < 0 || toIdx >= queue.value.length) return
const [item] = queue.value.splice(fromIdx, 1)
queue.value.splice(toIdx, 0, item)
}
function changeQueuePriority(id: string, priority: QueueItem['priority']): void {
const item = queue.value.find(q => q.id === id)
if (item) item.priority = priority
}
return {
agents,
openTasks,
feedEntries,
chatMessages,
irisBusy,
irisFocus,
irisRuntime,
queue,
runtimeSeconds,
getAgentRuntime,
startRuntime,
stopRuntime,
formatRuntime,
sendChat,
removeQueueItem,
moveQueueItem,
changeQueuePriority,
}
}
@@ -0,0 +1,266 @@
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
import type { AgentNodeData } from './useDashboardData'
export interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
export interface ConnectionPath {
d: string
length: number
}
export function useTeamNetworkSvg(
networkRef: Ref<HTMLElement | null>,
agents: Ref<AgentNodeData[]>,
heroId: Ref<string>,
isActive: (id: string) => boolean,
) {
// ── Layout ──
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
function updatePositions() {
const el = networkRef.value
if (!el) return
const rect = el.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = el.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(card => {
const id = card.getAttribute('data-agent-id')
if (!id) return
const r = card.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
}
// ── Connection paths ──
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const iris = pos[heroId.value]
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.38 + t * 0.24)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 70
const cp2x = endX + (isLeftColumn ? 35 : -35)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Path refs (template ref functions) ──
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
const pulseOffsets2 = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function storePulseRef2(id: string) {
return (el: SVGPathElement | null) => {
pulseElements2.value[id] = el
}
}
// ── Pulse animation ──
let animFrameId: number | null = null
let lastAnimTime = 0
const speeds: Record<string, number> = {}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
const pulseEl2 = pulseElements2.value[id]
if (pulseEl2 && p && p.length > 0) {
if (pulseOffsets2.value[id] === undefined) {
pulseOffsets2.value[id] = 0
}
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
}
}
}
function startPulseAnimation() {
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.value[id] = 0
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const pulseEl2 = pulseElements2.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const speed = speeds[id] ?? len / 3000
const cycleLen = len + 40
// Pulse 1
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + speed * dt
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
// Pulse 2 (offset by half cycle)
if (pulseEl2) {
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
pulseOffsets2.value[id] = offset2
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
}
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
return {
cardPositions,
svgWidth,
svgHeight,
childAgents,
connectionPaths,
pathElements,
pulseElements,
pulseElements2,
pulseOffsets,
pulseOffsets2,
storePathRef,
storePulseRef,
storePulseRef2,
updatePositions,
refreshPathLengths,
}
}
+37
View File
@@ -0,0 +1,37 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useTimer() {
const elapsed = ref(0)
let interval: ReturnType<typeof setInterval> | null = null
function start() {
if (interval !== null) return
const startTime = Date.now()
elapsed.value = 0
interval = setInterval(() => {
elapsed.value = Math.floor((Date.now() - startTime) / 1000)
}, 1000)
}
function stop() {
if (interval !== null) {
clearInterval(interval)
interval = null
}
}
function format(minutes: number, seconds: number): string {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
const formatted = (): string => {
const m = Math.floor(elapsed.value / 60)
const s = elapsed.value % 60
return format(m, s)
}
onMounted(start)
onUnmounted(stop)
return { elapsed, formatted, start, stop }
}
-2
View File
@@ -4,7 +4,6 @@ import ProjectDetailView from './views/ProjectDetailView.vue'
import SettingsView from './views/SettingsView.vue'
import MemoryView from './views/MemoryView.vue'
import DocsView from './views/DocsView.vue'
import TeamView from './views/TeamView.vue'
import AgentDetailView from './views/AgentDetailView.vue'
import AgentsIndexView from './views/AgentsIndexView.vue'
import SecurityView from './views/SecurityView.vue'
@@ -18,7 +17,6 @@ const routes = [
{ path: '/dashboard', name: 'Dashboard', component: DashboardView },
{ path: '/memory', name: 'Memory', component: MemoryView },
{ path: '/docs', name: 'Docs', component: DocsView },
{ path: '/team', name: 'Team', component: TeamView },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: '/security', name: 'Security', component: SecurityView },
{ path: '/incidents', name: 'Incidents', component: IncidentsView },
+1 -1
View File
@@ -41,7 +41,7 @@ main { min-width: 0; }
.connection.live { color: var(--green); }
.connection.preview { color: #e6b75d; }
.ask, .refresh { display: flex; align-items: center; gap: 7px; padding: 8px 11px; border: 1px solid #37315e; border-radius: 7px; background: #18152a; color: #c4bbff; font-size: 10px; cursor: pointer; }
.content { max-width: 1320px; margin: auto; padding: 36px 34px 60px; }
.content { padding: 16px 16px 60px; }
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
.eyebrow, .kicker { color: #7065c8; font-size: 9px; font-weight: 700; letter-spacing: .18em; }
h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
+177 -84
View File
@@ -1,116 +1,209 @@
<script setup lang="ts">
import IrisPanel from '../components/dashboard/IrisPanel.vue'
import { onMounted, onUnmounted, ref } from 'vue'
import TaskCard from '../components/dashboard/TaskCard.vue'
import OperationsFeed from '../components/dashboard/OperationsFeed.vue'
import AgendaPanel from '../components/dashboard/AgendaPanel.vue'
import ActiveInitiatives from '../components/dashboard/ActiveInitiatives.vue'
import RecentlyFinished from '../components/dashboard/RecentlyFinished.vue'
import TeamNetwork from '../components/dashboard/TeamNetwork.vue'
import ChatPanel from '../components/dashboard/ChatPanel.vue'
import QueuePanel from '../components/dashboard/QueuePanel.vue'
import AgentModal from '../components/dashboard/AgentModal.vue'
import { useDashboardData } from '../composables/useDashboardData'
import type { AgentNodeData } from '../composables/useDashboardData'
const {
agents, openTasks, feedEntries, chatMessages,
irisBusy, irisFocus, queue,
getAgentRuntime, startRuntime, stopRuntime,
sendChat, removeQueueItem, moveQueueItem, changeQueuePriority,
} = useDashboardData()
const selectedAgent = ref<AgentNodeData | null>(null)
function onAgentSelect(id: string) {
const agent = agents.value.find(a => a.id === id)
if (agent) selectedAgent.value = agent
}
onMounted(startRuntime)
onUnmounted(stopRuntime)
function onChatSend(text: string): void { sendChat(text) }
function onQueueMoveUp(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx > 0) moveQueueItem(idx, idx - 1)
}
function onQueueMoveDown(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx < queue.value.length - 1) moveQueueItem(idx, idx + 1)
}
function onQueueExecuteNow(id: string): void {
const item = queue.value.find(q => q.id === id)
if (item) console.log('[Dashboard] Execute now:', item.text)
}
</script>
<template>
<div class="dashboard">
<!-- Top Bar -->
<div class="topbar">
<span class="eyebrow">MISSION CONTROL</span>
<h1>Übersicht</h1>
<div class="col-left">
<section class="missions-section">
<TaskCard :tasks="openTasks" @new-task="console.log('New task requested')" @go-board="console.log('Go to Task Board')" />
</section>
<OperationsFeed :entries="feedEntries" />
</div>
<div class="col-center">
<!-- Quote Pill -->
<div class="quote-pill">
<span class="quote-text">"An autonomous organization of AI agents that does work for me and produces value 24/7"</span>
</div>
<!-- Three-column row -->
<div class="columns">
<IrisPanel />
<OperationsFeed />
<AgendaPanel />
<!-- Header -->
<div class="team-header">
<h1 class="team-title">AI Team Network</h1>
<p class="team-subtitle">{{ agents.length }} AI agents, connected in real-time.</p>
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten. Die Pulse zeigen aktive Kommunikationsflüsse.</p>
</div>
<!-- Bottom sections -->
<ActiveInitiatives />
<RecentlyFinished />
<TeamNetwork
hero-id="iris"
:agents="agents"
@select="onAgentSelect"
/>
<!-- Legend -->
<div class="legend-row">
<div class="legend-item">
<span class="legend-dot active-pulse"></span>
<span>Aktive Verbindung</span>
</div>
<div class="legend-item">
<span class="legend-dot idle-pulse"></span>
<span>Idle</span>
</div>
<div class="legend-item">
<span class="legend-dot pulse-dot"></span>
<span>Datenfluss (Pulse)</span>
</div>
</div>
</div>
<div class="col-right">
<ChatPanel :messages="chatMessages" :iris-busy="irisBusy" :iris-focus="irisFocus" @send="onChatSend" />
<QueuePanel :items="queue" @remove="removeQueueItem" @move-up="onQueueMoveUp" @move-down="onQueueMoveDown" @change-priority="changeQueuePriority" @execute-now="onQueueExecuteNow" />
</div>
<AgentModal
v-if="selectedAgent"
:agent="selectedAgent"
:runtime="getAgentRuntime(selectedAgent.id)"
@close="selectedAgent = null"
/>
</div>
</template>
<style scoped>
.dashboard {
--panel-bg: rgba(22, 27, 34, 0.8);
--panel-border: rgba(139, 124, 246, 0.12);
--text-primary: #e8eaf0;
--text-secondary: #7e8799;
--text-muted: #6b7385;
--iris-accent: #a78bfa;
--blue: #3b82f6;
--green: #22c55e;
--yellow: #eab308;
--red: #ef4444;
--gray: #6b7280;
--bg-base: #0d1117;
display: grid; grid-template-columns: 280px 1fr 320px; gap: 14px;
height: 100%; min-height: 0;
animation: fade-in 0.35s ease-out;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.dashboard ::-webkit-scrollbar { width: 5px; height: 5px; }
.dashboard ::-webkit-scrollbar-track { background: transparent; }
.dashboard ::-webkit-scrollbar-thumb { background: rgba(139,124,246,0.2); border-radius: 3px; }
.dashboard ::-webkit-scrollbar-thumb:hover { background: rgba(139,124,246,0.35); }
.col-left { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 4px; }
.col-center { overflow-y: auto; padding: 0 4px; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
display: flex;
flex-direction: column;
gap: 10px;
max-width: 1280px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--text-primary);
animation: dashboard-fade-in 0.4s ease-out;
/* Quote Pill */
.quote-pill {
background: var(--panel);
border: 1px solid rgba(139, 124, 246, 0.25);
border-radius: 14px;
padding: 14px 22px;
box-shadow: 0 0 18px rgba(139, 124, 246, 0.06), inset 0 0 18px rgba(139, 124, 246, 0.03);
text-align: center;
}
.quote-text {
font-style: italic;
font-size: 12px;
color: #9ea5b3;
line-height: 1.5;
}
@keyframes dashboard-fade-in {
from {
opacity: 0;
transform: translateY(8px);
/* Team Header */
.team-header {
text-align: center;
}
to {
opacity: 1;
transform: translateY(0);
.team-title {
font-size: 26px;
font-weight: 600;
color: #e8eaf0;
margin: 0 0 6px;
}
.team-subtitle {
font-size: 12px;
color: #7e8799;
margin: 0 0 4px;
}
.team-description {
font-size: 10.5px;
color: #6b7385;
margin: 0;
max-width: 560px;
margin-left: auto;
margin-right: auto;
}
/* Custom scrollbar */
.dashboard ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.dashboard ::-webkit-scrollbar-track {
background: transparent;
}
.dashboard ::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.dashboard ::-webkit-scrollbar-thumb:hover {
background: rgba(139, 124, 246, 0.35);
}
.topbar {
/* Legend */
.legend-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
justify-content: center;
gap: 24px;
padding: 12px 20px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
}
.eyebrow {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--iris-accent);
text-transform: uppercase;
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
color: #7e8799;
}
.topbar h1 {
font-size: 18px;
font-weight: 600;
margin: 0;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.columns {
display: grid;
grid-template-columns: 280px 1fr 260px;
gap: 10px;
.active-pulse {
background: #51d49a;
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
}
@media (max-width: 900px) {
.columns {
grid-template-columns: 1fr;
.idle-pulse {
background: #3a3f4b;
}
.topbar h1 {
font-size: 16px;
.pulse-dot {
background: white;
width: 6px;
height: 6px;
animation: legend-pulse 1.5s ease-in-out infinite;
}
@keyframes legend-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
.col-right { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-left: 4px; }
.missions-section { display: flex; flex-direction: column; gap: 8px; }
.column-title { margin: 0; font-size: 13px; font-weight: 600; color: #e8eaf0; letter-spacing: 0.01em; }
@media (max-width: 1100px) {
.dashboard { grid-template-columns: 1fr; }
.col-left, .col-center, .col-right { overflow: visible; padding: 0; }
}
</style>
+62 -151
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import AgentCard from '../components/team/AgentCard.vue'
import TeamNetwork from '../components/team/TeamNetwork.vue'
const router = useRouter()
@@ -32,25 +33,16 @@ const agents: AgentCardData[] = [
role: 'Lead Developer',
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
tags: ['coding', 'development', 'builds'],
color: '#4d8cf6',
color: '#3b82f6',
icon: 'code',
},
{
id: 'architekt',
name: 'Architekt',
role: 'Infrastructure Engineer',
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
tags: ['infrastructure', 'deployment', 'docker'],
color: '#4da8f6',
icon: 'server',
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'Code QA',
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
tags: ['Quality Assurance', 'Security', 'Code Review'],
color: '#f6a84d',
color: '#a855f7',
icon: 'shield',
},
{
@@ -59,23 +51,21 @@ const agents: AgentCardData[] = [
role: 'Research Analyst',
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
tags: ['Research', 'Analysis', 'Fact-Checking'],
color: '#8b4df6',
color: '#22c55e',
icon: 'search',
},
{
id: 'executor',
name: 'Executor',
name: 'DevOps',
role: 'Host Executor',
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
tags: ['Execution', 'Docker', 'VPS'],
color: '#4df6d4',
tags: ['Execution', 'Deployment', 'VPS'],
color: '#eab308',
icon: 'terminal',
},
]
const heroAgent = agents.find(a => a.hero)!
const operationAgents = agents.filter(a => !a.hero && ['programmer', 'architekt'].includes(a.id))
const specialistAgents = agents.filter(a => ['reviewer', 'researcher', 'executor'].includes(a.id))
const activeAgents = ref<string[]>(['programmer'])
function goToAgent(id: string) {
router.push(`/agents/${id}`)
@@ -91,72 +81,42 @@ function goToAgent(id: string) {
<!-- Header -->
<div class="team-header">
<h1 class="team-title">Meet the Team</h1>
<p class="team-subtitle">{{ agents.length }} AI agents, each with a real role and a real personality.</p>
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten.</p>
<h1 class="team-title">AI Team Network</h1>
<p class="team-subtitle">{{ agents.length }} AI agents, connected in real-time.</p>
<p class="team-description">Mission Control orchestriert ein Team spezialisierter Agenten jeder mit eigener Identität, eigenem Workspace und klaren Verantwortlichkeiten. Die Pulse zeigen aktive Kommunikationsflüsse.</p>
</div>
<!-- Hero Card -->
<div class="hero-section">
<AgentCard
v-bind="heroAgent"
@click="goToAgent"
<!-- Network Visualization -->
<div class="network-container">
<TeamNetwork
:agents="agents"
hero-id="iris"
:active-agents="activeAgents"
@select="goToAgent"
/>
</div>
<!-- Section Divider -->
<div class="section-divider">
<div class="divider-line"></div>
<span class="divider-label">OPERATIONS</span>
<div class="divider-line"></div>
<!-- Legend -->
<div class="legend-row">
<div class="legend-item">
<span class="legend-dot active-pulse"></span>
<span>Aktive Verbindung</span>
</div>
<!-- Operations Row -->
<div class="ops-row">
<AgentCard
v-for="agent in operationAgents"
:key="agent.id"
v-bind="agent"
@click="goToAgent"
/>
<div class="legend-item">
<span class="legend-dot idle-pulse"></span>
<span>Idle</span>
</div>
<!-- Connector Labels -->
<div class="connector-row">
<div class="connector-left">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 0L6 10M6 10L2 6M6 10L10 6" stroke="#51d49a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>INPUT SIGNAL</span>
<div class="legend-item">
<span class="legend-dot pulse-dot"></span>
<span>Datenfluss (Pulse)</span>
</div>
<div class="connector-rail">
<div class="rail-line"></div>
<div class="rail-dot"></div>
<div class="rail-line"></div>
</div>
<div class="connector-right">
<span>OUTPUT ACTION</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 12L6 2M6 2L2 6M6 2L10 6" stroke="#4d8cf6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<!-- Specialists Row -->
<div class="specialists-row">
<AgentCard
v-for="agent in specialistAgents"
:key="agent.id"
v-bind="agent"
@click="goToAgent"
/>
</div>
</div>
</template>
<style scoped>
.team-page {
max-width: 820px;
max-width: 920px;
margin: 0 auto;
padding-bottom: 40px;
}
@@ -201,99 +161,50 @@ function goToAgent(id: string) {
margin-right: auto;
}
.section-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 32px 0 24px;
position: relative;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--line);
}
.divider-label {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.1em;
color: #6b7385;
white-space: nowrap;
.network-container {
margin-top: 10px;
padding: 0;
}
.ops-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.connector-row {
.legend-row {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
padding: 0 6px;
justify-content: center;
gap: 24px;
margin-top: 28px;
padding: 12px 20px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
}
.connector-left {
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 8.5px;
font-weight: 700;
color: #51d49a;
letter-spacing: 0.08em;
white-space: nowrap;
gap: 8px;
font-size: 10px;
color: #7e8799;
}
.connector-right {
display: flex;
align-items: center;
gap: 5px;
font-size: 8.5px;
font-weight: 700;
color: #4d8cf6;
letter-spacing: 0.08em;
white-space: nowrap;
}
.connector-rail {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
}
.rail-line {
flex: 1;
height: 1px;
background: var(--line);
}
.rail-dot {
width: 5px;
height: 5px;
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #5b5286;
flex-shrink: 0;
}
.specialists-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
.active-pulse {
background: #51d49a;
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
}
@media (max-width: 720px) {
.ops-row {
grid-template-columns: 1fr;
.idle-pulse {
background: #3a3f4b;
}
.specialists-row {
grid-template-columns: 1fr;
}
.team-title {
font-size: 22px;
}
}
@media (min-width: 721px) and (max-width: 820px) {
.specialists-row {
grid-template-columns: 1fr 1fr;
.pulse-dot {
background: white;
width: 6px;
height: 6px;
animation: legend-pulse 1.5s ease-in-out infinite;
}
@keyframes legend-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
</style>