Files
nexus/frontend/src/components/dashboard/v2/AgentDetailModal.vue
T
AzuTear b0e65e3980
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
style: strengthen flow lines and tighten modal demo parity
2026-06-14 15:57:12 +02:00

615 lines
16 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">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { AgentDetailData } from './types'
import { icons } from '../../../composables/icons'
const props = defineProps<{
agent: AgentDetailData
agentOrder: string[]
}>()
const emit = defineEmits<{
close: []
select: [id: string]
changeModel: [agentId: string, modelId: string]
}>()
/* ── Progress animation ────────────────────────── */
const displayProgress = ref(0)
function animateProgress() {
displayProgress.value = 0
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
}
/* ── Typewriter ────────────────────────────────── */
const thinkDisplay = ref('')
let thinkTimer: ReturnType<typeof setInterval> | null = null
function startTypewriter() {
if (thinkTimer) clearInterval(thinkTimer)
thinkDisplay.value = ''
if (!props.agent.think) return
const text = props.agent.think
let i = 0
thinkTimer = setInterval(() => {
thinkDisplay.value = text.slice(0, i)
i = i >= text.length ? 0 : i + 1
}, 38)
}
/* ── Selected model ────────────────────────────── */
const selectedModel = ref(props.agent.model)
watch(() => props.agent.id, () => {
selectedModel.value = props.agent.model
animateProgress()
startTypewriter()
})
function selectModel(alias: string) {
selectedModel.value = alias
emit('changeModel', props.agent.id, alias)
}
/* ── Keyboard / Backdrop ───────────────────────── */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { 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 next = e.key === 'ArrowRight'
? (idx + 1) % props.agentOrder.length
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
emit('select', props.agentOrder[next])
}
}
function onBackdrop(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-ov')) emit('close')
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
animateProgress()
startTypewriter()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearInterval(thinkTimer)
})
const statusColors: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
}
function avatarLabel() {
if (props.agent.id === 'iris') return 'IR'
if (props.agent.name === 'Full-Stack Developer') return '</>'
return props.agent.name.slice(0, 2).toUpperCase()
}
</script>
<template>
<div class="modal-ov" @click="onBackdrop">
<div class="modal-card">
<!-- Close -->
<button class="m-close" @click="emit('close')">×</button>
<!-- Header -->
<div class="m-head">
<div :class="['m-av', { iris: agent.id === 'iris' }]">
{{ avatarLabel() }}
</div>
<div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
<span class="m-pill">{{ selectedModel }}</span>
<span class="m-status" :style="{ color: statusColors[agent.status] }">
<span class="dot" :class="agent.status"></span>
{{ agent.statusLabel }}
</span>
</div>
</div>
</div>
<!-- Aktuelle Aufgabe -->
<div v-if="agent.task" class="m-sec">
<h4>Aktuelle Aufgabe</h4>
<div class="m-task">{{ agent.task }}</div>
<div class="m-goal">
<span v-html="icons.target || ''"></span>
Ziel: {{ agent.goal }}
</div>
<div class="m-bar" :class="{ work: agent.status === 'work' }">
<i :style="{ width: displayProgress + '%' }"></i>
</div>
<div class="m-pct-row">
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
<div class="m-next"> {{ agent.next }}</div>
</div>
</div>
<!-- Metriken -->
<div class="m-sec">
<h4>Metriken</h4>
<div class="m-metrics">
<div class="m-metric">
<div class="mk">Elapsed</div>
<div class="mv">{{ agent.elapsed }}</div>
</div>
<div class="m-metric">
<div class="mk">Token</div>
<div class="mv">{{ agent.tokens }}</div>
</div>
<div class="m-metric">
<div class="mk">Kosten</div>
<div class="mv grad-tx">${{ agent.cost }}</div>
</div>
<div class="m-metric">
<div class="mk">Fortschritt</div>
<div class="mv">{{ agent.progress }}%</div>
</div>
</div>
</div>
<!-- Live Thinking -->
<div v-if="agent.think" class="m-sec">
<h4>Live Thinking</h4>
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</div>
<div v-if="agent.activity.length" class="m-sec">
<h4>Agent Activity</h4>
<div class="m-activity">
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
<span class="m-activity-time">{{ entry.time }}</span>
<span class="m-activity-text">{{ entry.text }}</span>
</div>
</div>
</div>
<!-- Modell wählen -->
<div class="m-sec">
<h4>Modell wählen</h4>
<div class="m-models">
<button
v-for="m in agent.availableModels"
:key="m.id"
:class="['m-model-btn', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>{{ m.alias }}</button>
</div>
</div>
<!-- MD Footer -->
<div v-if="agent.md" class="m-md">
<span class="dot work"></span>
Synced: <span class="m-md-path">{{ agent.md }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────── */
.modal-ov {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,4,16,.78);
backdrop-filter: blur(16px);
animation: ov-in .2s ease-out;
}
@keyframes ov-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Card ────────────────────────────────────── */
.modal-card {
width: 640px;
max-height: 88vh;
overflow-y: auto;
border-radius: 20px;
position: relative;
background: linear-gradient(160deg, rgba(22,18,50,.97), rgba(10,8,28,.97));
border: 1px solid rgba(150,140,255,.28);
box-shadow:
0 0 0 1px rgba(124,108,255,.15),
0 32px 80px -16px rgba(0,0,0,.8),
0 0 60px -10px rgba(124,108,255,.35);
animation: card-in .24s cubic-bezier(.2,.8,.3,1);
}
@keyframes card-in {
from { opacity: 0; transform: scale(.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-card::-webkit-scrollbar { width: 7px; }
.modal-card::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 7px;
border: 2px solid transparent;
background-clip: padding-box;
}
.modal-card::-webkit-scrollbar-track { background: transparent; }
/* ── Close ───────────────────────────────────── */
.m-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: .15s;
z-index: 2;
}
.m-close:hover {
background: rgba(124,108,255,.18);
color: var(--tx);
}
/* ── Head ────────────────────────────────────── */
.m-head {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 26px 26px 20px;
}
.m-av {
width: 56px;
height: 56px;
border-radius: 15px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 16px;
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-name {
font-family: 'Space Grotesk', sans-serif;
font-size: 21px;
font-weight: 700;
line-height: 1.1;
color: var(--tx);
}
.m-sub {
display: flex;
align-items: center;
gap: 9px;
margin-top: 7px;
flex-wrap: wrap;
}
/* ── Badges ──────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 11px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.m-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
}
.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,.5); animation: pulse-work 1.8s infinite; }
.dot.think { background: var(--st-think); box-shadow: 0 0 0 0 rgba(52,214,245,.5); animation: pulse-think 1.6s infinite; }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
/* ── Section ─────────────────────────────────── */
.m-sec {
padding: 16px 26px;
border-top: 1px solid var(--line);
}
.m-sec h4 {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
margin: 0 0 13px;
}
/* ── Task ────────────────────────────────────── */
.m-task {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
color: var(--tx);
}
.m-goal {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--tx-2);
margin-bottom: 14px;
}
.m-goal :deep(svg) {
width: 14px;
height: 14px;
color: var(--a-mid);
flex: 0 0 auto;
}
/* ── Progress Bar ────────────────────────────── */
.m-bar {
height: 10px;
border-radius: 10px;
background: rgba(124,108,255,.12);
overflow: hidden;
position: relative;
}
.m-bar i {
display: block;
height: 100%;
border-radius: 10px;
background: var(--grad);
box-shadow: 0 0 14px -2px rgba(124,108,255,.8);
transition: width .6s ease;
position: relative;
}
.m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
box-shadow: var(--glow-work);
}
.m-bar i::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
animation: shimmer 2s linear infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
.m-pct-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.m-pct {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
}
.grad-tx {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.m-next {
font-size: 12px;
color: var(--tx-3);
font-family: 'JetBrains Mono', monospace;
}
/* ── Metrics ─────────────────────────────────── */
.m-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.m-metric {
padding: 11px 13px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.mk {
font-size: 10px;
color: var(--tx-3);
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.mv {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-top: 5px;
color: var(--tx);
}
/* ── Live Thinking ───────────────────────────── */
.m-think {
background: rgba(5,20,36,.7);
border: 1px solid rgba(52,214,245,.2);
border-radius: 13px;
padding: 14px 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: #9fe8fb;
min-height: 72px;
position: relative;
overflow: hidden;
}
.m-think::before {
content: '▶ thinking';
position: absolute;
top: 10px;
right: 14px;
font-size: 9px;
color: var(--st-think);
letter-spacing: .12em;
opacity: .7;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
.m-activity {
display: flex;
flex-direction: column;
gap: 10px;
}
.m-activity-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 10px;
align-items: start;
padding: 10px 12px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.m-activity-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.m-activity-text {
font-size: 12px;
line-height: 1.45;
color: var(--tx-2);
}
/* ── Models ──────────────────────────────────── */
.m-models {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.m-model-btn {
height: 34px;
padding: 0 14px;
border-radius: 9px;
font-family: 'Manrope', sans-serif;
font-size: 12.5px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
color: var(--tx-2);
cursor: pointer;
transition: .15s;
}
.m-model-btn:hover {
border-color: var(--line-3);
color: var(--tx);
}
.m-model-btn.active {
background: var(--grad);
border: none;
color: #fff;
box-shadow: var(--glow-purple);
}
/* ── MD Footer ───────────────────────────────── */
.m-md {
display: flex;
align-items: center;
gap: 9px;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: var(--tx-2);
padding: 12px 26px;
border-top: 1px solid var(--line);
}
.m-md .dot { width: 7px; height: 7px; }
.m-md-path { color: var(--tx-3); }
</style>