Files
nexus/frontend/src/components/dashboard/v2/AgentDetailModal.vue
T
reviewer 6cedd8410f
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
refactor(frontend): deduplicate CSS keyframes, unify types, extract format utils, add UI states, trim mock data
- Remove duplicate @keyframes pulse-* from 3 component files (already in nexus-tokens.css)
- Rename AgentDetail → AgentDetailData in dashboard types to avoid collision with types/agent.ts
- Extract shared formatNumber/initials/formatTime to utils/format.ts
- Simplify FlowBoard: use agentStore modal/selection getters instead of duplicating local state
- Add error banner + empty state to IrisChat; add loading skeleton + error/empty states to TaskStrip
- Remove 105-line unused mockAgents array from useFlowLayout
- Reduce operations store fallbacks from hardcoded preview data to minimal safe defaults
- Update operations store tests to match lean fallback structure
- Net: -73 lines, cleaner imports, fewer magic strings
2026-06-12 17:02:50 +02:00

764 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* AgentDetailModal — Detailansicht für einen Agenten im V2 Dashboard
*
* Props:
* agent AgentDetail (s. Interface unten)
*
* Emits:
* close Modal schließen
* select(id) Zum nächsten/vorherigen Agenten springen
* changeModel(id, modelId) Modell wechseln
*/
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { ThinkingItem, AgentDetailData } from './types'
import { formatNumber } from '../../../utils/format'
/* ── Props ──────────────────────────────────────────── */
const props = defineProps<{
agent: AgentDetailData
// Agent-Liste für Pfeilnavigation (IDs in Anzeigereihenfolge)
agentOrder: string[]
}>()
const emit = defineEmits<{
close: []
select: [id: string]
changeModel: [agentId: string, modelId: string]
}>()
/* ── Model Dropdown ────────────────────────────────── */
const modelDropdownOpen = ref(false)
const selectedModel = ref(props.agent.model)
watch(
() => props.agent.id,
() => {
selectedModel.value = props.agent.model
modelDropdownOpen.value = false
}
)
function toggleModelDropdown() {
modelDropdownOpen.value = !modelDropdownOpen.value
}
function selectModel(modelId: string) {
selectedModel.value = modelId
modelDropdownOpen.value = false
emit('changeModel', props.agent.id, modelId)
}
/* ── Keyboard Navigation ──────────────────────────── */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
emit('close')
return
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const idx = props.agentOrder.indexOf(props.agent.id)
if (idx === -1) return
const nextIdx = e.key === 'ArrowRight'
? (idx + 1) % props.agentOrder.length
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
emit('select', props.agentOrder[nextIdx])
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
/* ── Backdrop Click ───────────────────────────────── */
function onBackdropClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-backdrop')) {
emit('close')
}
}
/* ── Metrics Config ───────────────────────────────── */
interface MetricDef {
label: string
value: string
sub?: string
}
function getMetrics(a: AgentDetailData): MetricDef[] {
return [
{
label: 'Tasks aktiv',
value: String(a.activeTaskCount),
sub: 'aktuelle Aufgaben',
},
{
label: 'Tokens heute',
value: formatNumber(a.tokensToday),
sub: 'verbraucht',
},
{
label: 'Kosten',
value: '$' + a.costToday.toFixed(2),
sub: 'heute gesamt',
},
{
label: 'Workload',
value: a.workload + '%',
sub: a.workload > 70 ? 'Ausgelastet' : a.workload > 30 ? 'Moderat' : 'Gering',
},
{
label: 'Uptime',
value: a.uptime,
sub: 'aktuelle Session',
},
{
label: 'Letzte Aktivität',
value: a.lastActive,
sub: '',
},
]
}
/* ── Pretty Model Name ────────────────────────────── */
function modelLabel(alias: string): string {
return alias
}
/* ── Thinking type helpers ─────────────────────────── */
const typeConfig: Record<ThinkingItem['type'], { dotClass: string; label: string }> = {
thought: { dotClass: 'dot-thought', label: 'Thought' },
action: { dotClass: 'dot-action', label: 'Action' },
result: { dotClass: 'dot-result', label: 'Result' },
}
</script>
<template>
<div class="modal-backdrop" @click="onBackdropClick">
<div class="modal-panel" role="dialog" aria-modal="true">
<!-- Close Button -->
<button class="m-close" @click="emit('close')" aria-label="Schließen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="close-icon">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
<!-- Header -->
<div class="m-head">
<!-- Avatar -->
<div :class="['m-av', { 'm-av-iris': agent.id === 'iris' }]">
<span>{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}</span>
</div>
<div class="m-head-info">
<div class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span class="m-role">{{ agent.role }}</span>
<span class="m-status-pill">
<span :class="['dot', `dot-${agent.status}`]"></span>
{{ agent.status === 'work' ? 'Arbeitet' : agent.status === 'think' ? 'Denkt' : 'Bereit' }}
</span>
</div>
</div>
<!-- Model Info + Dropdown -->
<div class="m-model-area">
<div class="m-model-current">
<span class="m-model-label">Model</span>
<button class="m-model-btn" @click="toggleModelDropdown">
<span class="m-model-name">{{ selectedModel }}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
<!-- Dropdown Menu -->
<div v-if="modelDropdownOpen" class="m-model-dropdown">
<button
v-for="m in agent.availableModels"
:key="m.id"
:class="['m-model-option', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>
<span class="option-check" v-if="m.alias === selectedModel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="check-icon">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
{{ modelLabel(m.alias) }}
</button>
</div>
</div>
</div>
<!-- Metrics Grid (3 columns, 6 items) -->
<div class="m-metrics">
<div v-for="(metric, idx) in getMetrics(agent)" :key="idx" class="m-metric">
<div class="m-metric-label">{{ metric.label }}</div>
<div class="m-metric-value">{{ metric.value }}</div>
<div v-if="metric.sub" class="m-metric-sub">{{ metric.sub }}</div>
</div>
</div>
<!-- Thinking Feed -->
<div v-if="agent.thinking.length > 0" class="m-feed">
<div class="m-feed-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="feed-icon">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4l3 3" />
</svg>
Live Thinking
</div>
<div class="m-feed-items">
<div
v-for="(item, idx) in agent.thinking"
:key="idx"
class="m-feed-item"
>
<div class="feed-item-header">
<span :class="['feed-dot', typeConfig[item.type].dotClass]"></span>
<span class="feed-type-label">{{ typeConfig[item.type].label }}</span>
</div>
<div class="feed-item-text">{{ item.text }}</div>
<div class="feed-item-ts">{{ item.ts }}</div>
</div>
</div>
</div>
<!-- Empty Thinking State -->
<div v-else class="m-feed">
<div class="m-feed-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="feed-icon">
<circle cx="12" cy="12" r="9" />
<path d="M12 8v4l3 3" />
</svg>
Live Thinking
</div>
<div class="m-feed-empty">
<span class="empty-text">Keine aktiven Gedanken</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Backdrop ──────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(4, 2, 12, 0.72);
backdrop-filter: blur(8px);
animation: backdrop-in 0.2s ease-out;
}
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Panel ─────────────────────────────────────────── */
.modal-panel {
width: min(680px, 92vw);
max-height: 86vh;
overflow-y: auto;
border-radius: var(--r);
position: relative;
background: linear-gradient(145deg, rgba(14, 12, 28, 0.96), rgba(8, 6, 20, 0.96));
border: 1px solid var(--line);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
animation: panel-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes panel-in {
from {
opacity: 0;
transform: scale(0.94) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-panel::-webkit-scrollbar {
width: 7px;
}
.modal-panel::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.22);
border-radius: 7px;
border: 2px solid transparent;
background-clip: padding-box;
}
.modal-panel::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 255, 0.4);
background-clip: padding-box;
}
.modal-panel::-webkit-scrollbar-track {
background: transparent;
}
/* ── Close Button ──────────────────────────────────── */
.m-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
display: grid;
place-items: center;
transition: background 0.15s, color 0.15s;
z-index: 3;
}
.m-close:hover {
background: rgba(124, 108, 255, 0.12);
color: var(--tx);
}
.close-icon {
width: 18px;
height: 18px;
}
/* ── Header ────────────────────────────────────────── */
.m-head {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 22px 24px 16px;
padding-right: 56px; /* Platz für Close-Button */
}
.m-av {
width: 40px;
height: 40px;
border-radius: 11px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 13px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.m-av-iris {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.m-av span {
line-height: 1;
}
.m-head-info {
flex: 1;
min-width: 0;
}
.m-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 16px;
color: var(--tx);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.m-sub {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
flex-wrap: wrap;
}
.m-role {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
}
.m-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: 'Manrope', sans-serif;
font-size: 11px;
font-weight: 600;
color: var(--tx-2);
}
/* Status dots in pill */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot-work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
animation: pulse-work 1.8s infinite;
}
.dot-think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
animation: pulse-think 1.6s infinite;
}
.dot-idle {
background: var(--st-idle);
}
/* ── Model Area ────────────────────────────────────── */
.m-model-area {
flex: 0 0 auto;
position: relative;
}
.m-model-current {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.m-model-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
letter-spacing: 0.05em;
}
.m-model-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: rgba(124, 108, 255, 0.06);
color: var(--tx-2);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
}
.m-model-btn:hover {
background: rgba(124, 108, 255, 0.12);
border-color: var(--line-3);
color: var(--tx);
}
.m-model-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron-down {
width: 13px;
height: 13px;
flex: 0 0 auto;
opacity: 0.6;
}
/* ── Model Dropdown ────────────────────────────────── */
.m-model-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 180px;
background: linear-gradient(145deg, rgba(16, 14, 32, 0.98), rgba(10, 8, 22, 0.98));
border: 1px solid var(--line-2);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
padding: 4px;
z-index: 10;
animation: dropdown-in 0.12s ease-out;
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.m-model-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: 7px;
background: transparent;
border: none;
color: var(--tx-2);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
text-align: left;
white-space: nowrap;
}
.m-model-option:hover {
background: rgba(124, 108, 255, 0.10);
color: var(--tx);
}
.m-model-option.active {
color: var(--tx);
background: rgba(124, 108, 255, 0.14);
}
.option-check {
width: 16px;
height: 16px;
flex: 0 0 auto;
display: grid;
place-items: center;
}
.check-icon {
width: 14px;
height: 14px;
color: var(--a-mid);
}
/* ── Metrics Grid ──────────────────────────────────── */
.m-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 0 24px 18px;
}
.m-metric {
background: var(--glass);
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 2px;
transition: border-color 0.15s;
}
.m-metric:hover {
border-color: var(--line-2);
}
.m-metric-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.m-metric-value {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 20px;
color: var(--tx);
line-height: 1.1;
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
.m-metric-sub {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
margin-top: 2px;
}
/* ── Feed ──────────────────────────────────────────── */
.m-feed {
padding: 0 24px 22px;
}
.m-feed-title {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 12px;
color: var(--tx);
margin-bottom: 12px;
}
.feed-icon {
width: 15px;
height: 15px;
color: var(--a-mid);
flex: 0 0 auto;
}
.m-feed-items {
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
max-height: 340px;
padding-right: 4px;
}
.m-feed-items::-webkit-scrollbar {
width: 5px;
}
.m-feed-items::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.18);
border-radius: 5px;
border: 1px solid transparent;
background-clip: padding-box;
}
.m-feed-items::-webkit-scrollbar-track {
background: transparent;
}
.m-feed-item {
padding: 11px 14px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
transition: background 0.15s;
}
.m-feed-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.feed-item-header {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 5px;
}
.feed-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
display: block;
}
.dot-thought {
background: var(--st-think);
box-shadow: 0 0 5px rgba(52, 214, 245, 0.4);
}
.dot-action {
background: var(--a-purple);
box-shadow: 0 0 5px rgba(181, 87, 246, 0.4);
}
.dot-result {
background: var(--st-work);
box-shadow: 0 0 5px rgba(61, 220, 151, 0.4);
}
.feed-type-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dot-thought ~ .feed-type-label {
color: var(--st-think);
}
.dot-action ~ .feed-type-label {
color: var(--a-purple);
}
.dot-result ~ .feed-type-label {
color: var(--st-work);
}
.feed-item-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
line-height: 1.5;
color: var(--tx);
}
.feed-item-ts {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* Empty state */
.m-feed-empty {
padding: 20px 0;
text-align: center;
}
.empty-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
}
</style>