fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
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 2s

This commit is contained in:
2026-06-11 10:06:53 +02:00
parent a538025049
commit b7b44494f0
59 changed files with 3267 additions and 1153 deletions
+7 -27
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore()
const auth = useAuthStore()
@@ -82,32 +83,11 @@ onMounted(() => {
</template>
</section>
</main>
<ToastContainer />
</div>
</template>
<style scoped>
:root {
--bg: #0b0d13;
--panel: #11141b;
--line: #1f2330;
--accent: #7b6ef2;
--accent-soft: rgba(123,110,242,.08);
--text: #e8eaf0;
--text-dim: #6f7889;
--green: #27ae60;
--red: #e74c3c;
--yellow: #f1c40f;
--orange: #e67e22;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.shell {
display: flex;
height: 100vh;
@@ -135,12 +115,12 @@ main {
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent);
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
@@ -149,15 +129,15 @@ main {
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--text-dim);
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; }
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
+681
View File
@@ -0,0 +1,681 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 252 80% 74%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 252 80% 74%;
--radius: 0.5rem;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
* {
border-color: hsl(var(--border));
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* Nexus overrides for existing CSS variables used in dashboard */
:root {
--nx-bg: #080a0f;
--nx-panel: #10131a;
--nx-panel-soft: #0d1016;
--nx-line: #202530;
--nx-muted: #7e8799;
--nx-accent: #8b7cf6;
--nx-accent-soft: rgba(139, 124, 246, 0.12);
--nx-green: #51d49a;
--nx-text: #e8eaf0;
--nx-text-dim: #6f7889;
}
/* ── Existing Nexus layout styles ── */
*,
*::before,
*::after {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 224px 1fr;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 22px 14px 14px;
border-right: 1px solid #1a1e27;
background: rgba(9, 11, 16, 0.94);
backdrop-filter: blur(18px);
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 0 8px 25px;
}
.brand-mark {
width: 35px;
height: 35px;
display: grid;
place-items: center;
border: 1px solid #443d7c;
border-radius: 10px;
background: linear-gradient(145deg, #241f44, #12121f);
color: #b8adff;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
}
.brand strong {
display: block;
font-size: 13px;
letter-spacing: 0.14em;
}
.brand span,
.owner span {
display: block;
color: var(--nx-muted);
font-size: 10px;
margin-top: 2px;
}
.nav {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav button,
.sidebar-bottom > button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
padding: 9px 10px;
border-radius: 7px;
background: transparent;
color: #8991a1;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.nav button:hover,
.nav button.active {
color: #ececf5;
background: var(--nx-accent-soft);
}
.nav button.active {
box-shadow: inset 2px 0 var(--nx-accent);
}
.nav button i {
margin-left: auto;
padding: 1px 6px;
border: 1px solid #343947;
border-radius: 8px;
font-size: 9px;
font-style: normal;
}
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid #1b1f28;
padding-top: 10px;
}
.owner {
display: grid;
grid-template-columns: 31px 1fr auto;
gap: 9px;
align-items: center;
margin-top: 10px;
padding: 10px 8px;
width: 100%;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.owner:hover {
background: var(--nx-accent-soft);
border-radius: 8px;
}
.owner strong {
font-size: 11px;
}
.avatar {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border-radius: 50%;
background: #28243f;
color: #bcb3ff;
font-size: 10px;
}
main {
min-width: 0;
}
.topbar {
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
border-bottom: 1px solid #191d25;
background: rgba(8, 10, 15, 0.68);
backdrop-filter: blur(16px);
}
.search {
width: min(390px, 42vw);
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border: 1px solid #202530;
border-radius: 7px;
color: #6f7889;
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 2px 5px;
border: 1px solid #2c313d;
border-radius: 4px;
color: #606979;
font-size: 9px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.connection {
display: flex;
gap: 6px;
align-items: center;
font-size: 10px;
color: #8c95a5;
}
.connection.live {
color: var(--nx-green);
}
.connection.preview {
color: #e6b75d;
}
.ask,
.refresh-btn {
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 {
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: 0.18em;
}
h1 {
margin: 7px 0 5px;
font-size: 27px;
letter-spacing: -0.04em;
}
.page-heading p,
.placeholder p {
margin: 0;
color: var(--nx-muted);
font-size: 11px;
}
.refresh-btn {
border-color: var(--nx-line);
background: var(--nx-panel);
color: #a5adba;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mobile-menu {
display: none;
border: 0;
background: transparent;
color: #aaa4e7;
}
/* ── Keep existing module/layout styles for non-dashboard pages ── */
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.metrics article,
.panel {
border: 1px solid var(--nx-line);
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
border-radius: 9px;
}
.metrics article {
padding: 16px 17px;
}
.metrics span {
color: #717a8a;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
}
.metrics strong {
display: block;
margin: 7px 0 5px;
font-size: 24px;
letter-spacing: -0.04em;
}
.metrics small {
color: #687181;
font-size: 9px;
}
.metrics small.up {
color: #55c995;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.panel {
padding: 18px;
min-height: 180px;
}
.span-2 {
grid-column: span 2;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 1px solid #1d222c;
}
.panel-head h2 {
margin: 4px 0 0;
font-size: 13px;
}
.panel-head button {
border: 0;
background: transparent;
color: #8e96a5;
font-size: 9px;
}
.badge {
padding: 4px 8px;
border-radius: 10px;
font-size: 8px;
}
.badge.positive {
color: var(--nx-green);
background: rgba(81, 212, 154, 0.1);
}
.badge.warning {
color: #e7b660;
background: rgba(231, 182, 96, 0.1);
}
.badge.negative {
color: #e16e75;
background: rgba(225, 110, 117, 0.1);
}
.runtime-row {
display: flex;
align-items: center;
gap: 12px;
padding-top: 22px;
}
.runtime-icon {
width: 45px;
height: 45px;
display: grid;
place-items: center;
border-radius: 9px;
color: #ad9fff;
background: var(--nx-accent-soft);
}
.runtime-main strong,
.model strong,
.project strong,
.event strong {
display: block;
font-size: 11px;
}
.runtime-main span,
.model small,
.event small {
display: block;
margin-top: 4px;
color: var(--nx-muted);
font-size: 9px;
}
.pulse-bars {
height: 42px;
display: flex;
align-items: center;
gap: 3px;
margin-left: auto;
}
.pulse-bars i {
width: 3px;
min-height: 5px;
border-radius: 3px;
background: linear-gradient(#927fff, #443b7c);
}
.model {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 9px;
padding: 12px 2px;
border-bottom: 1px solid #1b2029;
}
.model > span:last-child {
color: #687181;
font-size: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #657083;
}
.status-dot.online {
background: var(--nx-green);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
}
.status-dot.offline {
background: #e16e75;
}
.project {
display: grid;
grid-template-columns: 34px 1fr auto;
align-items: center;
gap: 11px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.project-letter {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border: 1px solid #353047;
border-radius: 7px;
color: #a99cf5;
font-size: 10px;
}
.project-info > div:first-child {
display: flex;
justify-content: space-between;
}
.project-info span {
color: var(--nx-muted);
font-size: 8px;
}
.project b {
color: #838c9c;
font-size: 9px;
}
.progress {
height: 3px;
margin-top: 8px;
overflow: hidden;
border-radius: 4px;
background: #242936;
}
.progress i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff);
}
.event {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.event > span {
width: 6px;
height: 6px;
margin-top: 4px;
border-radius: 50%;
background: #657083;
}
.event > span.runtime {
background: var(--nx-green);
}
.event > span.deploy {
background: #8b7cf6;
}
.event > span.security {
background: #e5ad52;
}
.placeholder {
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.placeholder svg {
margin-bottom: 18px;
color: #8074d8;
}
.placeholder h2 {
margin: 8px 0;
}
/* Animations */
.animate-spin {
animation: spin 1s linear infinite;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
z-index: 20;
left: -240px;
width: 224px;
transition: left 0.2s ease;
}
.sidebar.open {
left: 0;
box-shadow: 20px 0 60px #000;
}
.mobile-menu {
display: block;
}
.topbar {
padding: 0 18px;
}
.metrics {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
}
@media (max-width: 560px) {
.content {
padding: 26px 16px 40px;
}
.search {
display: none;
}
.topbar {
justify-content: space-between;
}
.metrics {
grid-template-columns: 1fr 1fr;
}
.page-heading {
align-items: start;
}
.page-heading p {
max-width: 220px;
line-height: 1.5;
}
.runtime-row {
flex-wrap: wrap;
}
.pulse-bars {
width: 100%;
margin-left: 57px;
}
}
+3 -3
View File
@@ -361,7 +361,7 @@ async function sendMessage() {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--accent);
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
@@ -411,7 +411,7 @@ async function sendMessage() {
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
@@ -499,7 +499,7 @@ async function sendMessage() {
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--accent);
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
+163 -144
View File
@@ -1,15 +1,84 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { X, ExternalLink } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useToast } from '../../composables/useToast'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Select from '@/components/ui/Select.vue'
defineProps<{
const props = defineProps<{
agent: AgentNodeData
runtime: string
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
const toast = useToast()
interface ModelOption {
id: string
name: string
provider: string
}
const availableModels = ref<ModelOption[]>([])
const selectedModel = ref('')
const currentModel = ref('')
const saving = ref(false)
async function loadModels() {
try {
const res = await fetch('/api/dashboard/models', { credentials: 'include' })
if (res.ok) {
availableModels.value = await res.json()
}
} catch {
// silent
}
}
async function loadCurrentModel() {
try {
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
selectedModel.value = data.model
currentModel.value = data.model
}
} catch {
// silent
}
}
async function saveModel() {
saving.value = true
try {
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selectedModel.value }),
})
if (res.ok) {
currentModel.value = selectedModel.value
toast.success('Model updated successfully')
} else {
toast.error('Failed to update model')
}
} catch {
toast.error('Connection error')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadModels()
await loadCurrentModel()
})
</script>
<template>
@@ -30,47 +99,56 @@ defineEmits<{
</a>
</div>
</div>
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('close')" aria-label="Close">
<X :size="16" />
</button>
</Button>
</div>
<!-- Status -->
<section class="modal-section">
<h3 class="section-label">Status</h3>
<div class="status-row">
<Badge
:variant="agent.active ? 'default' : 'secondary'"
:class="agent.active ? 'bg-[rgba(81,212,154,0.1)] text-[#51d49a] border-[rgba(81,212,154,0.3)]' : 'bg-[rgba(107,115,133,0.08)] text-[#6b7385] border-[rgba(107,115,133,0.2)]'"
>
<span class="status-dot" :class="{ active: agent.active }" />
{{ agent.active ? 'Active' : 'Idle' }}
</Badge>
<span v-if="agent.active" class="footer-badge">Runtime: {{ runtime }}</span>
</div>
</section>
<!-- Description -->
<p class="modal-desc">{{ agent.description }}</p>
<!-- Tags -->
<section class="modal-section">
<h3 class="section-label">Tags</h3>
<div class="modal-tags-row">
<Badge
v-for="tag in agent.tags"
:key="tag"
variant="outline"
:style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }"
>
{{ tag }}
</Badge>
</div>
</section>
<!-- Current Task -->
<section class="modal-section">
<h3 class="section-label">Current Task</h3>
<p class="section-value">
{{ agent.currentTask }}
<span class="thinking-dots">
<span v-if="agent.active" 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>
@@ -78,33 +156,38 @@ defineEmits<{
<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 class="progress-fill" :style="{ width: `${agent.progress}%` }"></div>
</div>
</div>
</section>
<!-- Working Feed -->
<!-- Model -->
<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"
<h3 class="section-label">Model</h3>
<div class="model-select-row">
<Select v-model="selectedModel" class="flex-1 text-xs border-[#a78bfa]">
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.provider }})
</option>
</Select>
<Button
variant="default"
size="sm"
class="bg-[#a78bfa] hover:bg-[#c4b5fd]"
:disabled="saving || selectedModel === currentModel"
@click="saveModel"
>
<span class="step-dot"></span>
<span class="step-time">{{ step.time }}</span>
<span class="step-text">{{ step.text }}</span>
</div>
{{ saving ? 'Saving...' : 'Save' }}
</Button>
</div>
</section>
<!-- Footer Stats -->
<div class="modal-footer">
<span class="footer-badge">Runtime: {{ runtime }}</span>
<Badge :class="agent.active ? 'bg-[rgba(81,212,154,0.06)] text-[#51d49a] border-[rgba(81,212,154,0.25)]' : 'bg-[rgba(107,115,133,0.04)] text-[#6b7385] border-[rgba(107,115,133,0.15)]'">
{{ agent.active ? '● Active' : '○ Idle' }}
</Badge>
<span v-if="agent.active" class="footer-badge">{{ runtime }}</span>
</div>
</div>
</div>
@@ -213,24 +296,6 @@ defineEmits<{
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;
@@ -261,6 +326,36 @@ defineEmits<{
gap: 8px;
}
/* Status */
.status-row {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7385;
transition: all 0.3s ease;
}
.status-dot.active {
background: #51d49a;
box-shadow: 0 0 8px rgba(81, 212, 154, 0.6);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.2); }
}
/* Tags */
.modal-tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* Progress */
.progress-row {
display: flex;
@@ -289,21 +384,7 @@ defineEmits<{
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 */
.thinking-dots {
display: inline-flex;
gap: 6px;
@@ -333,75 +414,6 @@ defineEmits<{
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;
@@ -419,4 +431,11 @@ defineEmits<{
border: 1px solid rgba(255, 255, 255, 0.06);
color: #7e8799;
}
/* Model Selector */
.model-select-row {
display: flex;
gap: 8px;
align-items: center;
}
</style>
@@ -1,208 +0,0 @@
<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,90 @@
<script setup lang="ts">
import { Bot } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
import { renderMarkdown } from '../../utils/markdown'
defineProps<{
messages: ChatMessage[]
irisBusy: boolean
elapsedSeconds: number
}>()
function formatTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
v-for="msg in messages"
:key="msg.id"
:class="['flex gap-2', msg.sender === 'user' ? 'justify-end' : '']"
>
<div v-if="msg.sender === 'iris'" class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
<Bot :size="12" />
</div>
<div
:class="[
'px-3 py-2 rounded-lg text-[10.5px] leading-[1.45] max-w-[85%]',
msg.sender === 'iris'
? 'bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.1)] text-[#d4d8e0]'
: 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.15)] text-[#e8eaf0]',
]"
>
<div v-if="msg.sender === 'iris'" v-html="renderMarkdown(msg.text)" class="msg-md"></div>
<p v-else class="m-0">{{ msg.text }}</p>
<div
:class="[
'text-[8px] mt-1 opacity-50',
msg.sender === 'user' ? 'text-right' : 'text-left',
]"
>
{{ formatTime(msg.timestamp) }}
</div>
</div>
</div>
<!-- Busy Bubble -->
<div v-if="irisBusy" class="flex gap-2 max-w-[75%]">
<div class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
<Bot :size="12" />
</div>
<div class="px-3 py-2 rounded-lg text-[10.5px] bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.18)] text-[#c4c8d4]">
<div class="flex items-center gap-2">
<span class="w-[7px] h-[7px] rounded-full bg-[#a78bfa] animate-pulse flex-shrink-0" />
<span>Denkt nach...</span>
</div>
<div class="text-[8px] mt-1 opacity-50">läuft seit {{ elapsedSeconds }}s</div>
</div>
</div>
<div v-if="messages.length === 0" class="flex-1 grid place-items-center text-center py-8">
<p class="text-[10px] text-[#6b7385] max-w-[180px] leading-[1.4] m-0">
No messages yet. Start a conversation with Iris.
</p>
</div>
</div>
</template>
<style scoped>
.msg-md { line-height: 1.55; }
.msg-md :deep(p) { margin: 0 0 6px 0; }
.msg-md :deep(p:last-child) { margin-bottom: 0; }
.msg-md :deep(strong) { color: #e8eaf0; font-weight: 700; }
.msg-md :deep(em) { font-style: italic; color: #c4c8d4; }
.msg-md :deep(code) { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 4px; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 10px; color: #f472b6; }
.msg-md :deep(pre) { background: rgba(0,0,0,0.35); padding: 8px 10px; border-radius: 8px; overflow-x: auto; margin: 6px 0; border: 1px solid rgba(255,255,255,0.04); }
.msg-md :deep(pre code) { background: none; padding: 0; font-size: 10px; color: #d4d8e0; }
.msg-md :deep(a) { color: #a78bfa; text-decoration: underline; text-underline-offset: 2px; }
.msg-md :deep(a:hover) { color: #c4b5fd; }
.msg-md :deep(ul) { margin: 4px 0; padding-left: 16px; }
.msg-md :deep(li) { margin: 2px 0; }
.msg-md :deep(h1), .msg-md :deep(h2), .msg-md :deep(h3), .msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { margin: 8px 0 4px 0; color: #e8eaf0; font-weight: 700; line-height: 1.3; }
.msg-md :deep(h1) { font-size: 13px; }
.msg-md :deep(h2) { font-size: 12px; }
.msg-md :deep(h3) { font-size: 11px; }
.msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { font-size: 10.5px; }
.msg-md :deep(hr) { border: none; border-top: 1px solid rgba(255,255,255,0.08); margin: 8px 0; }
</style>
+134 -341
View File
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { Bot, Send, LoaderCircle, Maximize2, X } from '@lucide/vue'
import { ref, nextTick, watch, onUnmounted } from 'vue'
import { Bot, Send, Maximize2 } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
import { useDashboardData } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/Textarea.vue'
import Dialog from '@/components/ui/Dialog.vue'
import DialogHeader from '@/components/ui/DialogHeader.vue'
import DialogTitle from '@/components/ui/DialogTitle.vue'
import ChatMessageList from './ChatMessageList.vue'
const props = defineProps<{
messages: ChatMessage[]
@@ -10,12 +16,46 @@ const props = defineProps<{
irisFocus: string
}>()
const { sendChatMessage } = useDashboardData()
const { sendChatMessage, busySince } = useDashboardData()
const elapsedSeconds = ref(0)
let elapsedInterval: ReturnType<typeof setInterval> | null = null
function startElapsedTimer(): void {
stopElapsedTimer()
const update = () => {
if (busySince.value > 0) {
elapsedSeconds.value = Math.floor((Date.now() - busySince.value) / 1000)
}
}
update()
elapsedInterval = setInterval(update, 1000)
}
function stopElapsedTimer(): void {
if (elapsedInterval) {
clearInterval(elapsedInterval)
elapsedInterval = null
}
}
watch(() => props.irisBusy, (busy) => {
if (busy) {
startElapsedTimer()
} else {
stopElapsedTimer()
elapsedSeconds.value = 0
}
}, { immediate: true })
onUnmounted(() => {
stopElapsedTimer()
})
const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null)
const chatModalListRef = ref<HTMLElement | null>(null)
const chatModalOpen = ref(false)
const dialogOpen = ref(false)
function sendMessage(): void {
if (!inputText.value.trim()) return
@@ -27,7 +67,7 @@ watch(
() => props.messages.length,
async () => {
await nextTick()
const el = chatModalOpen.value ? chatModalListRef.value : chatListRef.value
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
@@ -36,19 +76,16 @@ watch(
</script>
<template>
<!-- Inline Chat Panel -->
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="chat-header-icon" />
<Bot :size="16" class="text-[#a78bfa]" />
<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">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</button>
</Button>
</div>
<!-- Focus Bar -->
@@ -59,104 +96,80 @@ watch(
<!-- 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>
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<!-- Input -->
<div class="chat-input-row">
<input
<Textarea
v-model="inputText"
type="text"
rows="1"
placeholder="Type a message..."
@keyup.enter="sendMessage"
class="min-h-0 h-9 resize-none text-xs bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385] text-[10px]"
@keyup.enter.exact="sendMessage"
/>
<button
class="send-btn"
<Button
size="icon"
class="h-8 w-8 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="14" />
</button>
</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>
<!-- Expanded Chat Dialog -->
<Dialog :open="dialogOpen" class="sm:max-w-[820px] sm:h-[78vh] p-0 gap-0" @update:open="dialogOpen = $event">
<template #default>
<DialogHeader class="flex-row items-center justify-between px-5 py-4 border-b border-[rgba(255,255,255,0.06)]">
<div class="flex items-center gap-2">
<Bot :size="18" class="text-[#a78bfa]" />
<DialogTitle>Iris Chat</DialogTitle>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = false" aria-label="Close">
<span class="text-lg leading-none">&times;</span>
</Button>
</DialogHeader>
<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 v-if="irisBusy && irisFocus" class="focus-bar !px-5">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
</div>
</Teleport>
<div ref="chatModalListRef" class="flex-1 overflow-y-auto px-5 py-4">
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<div class="flex gap-2 px-4 py-3 border-t border-[rgba(255,255,255,0.06)]">
<Textarea
v-model="inputText"
rows="1"
placeholder="Type a message..."
class="min-h-0 h-10 resize-none text-sm bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385]"
@keyup.enter.exact="sendMessage"
/>
<Button
size="icon"
class="h-10 w-10 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="18" />
</Button>
</div>
</template>
</Dialog>
</template>
<style scoped>
@@ -191,54 +204,12 @@ watch(
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 {
@@ -267,9 +238,6 @@ watch(
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
@@ -282,59 +250,6 @@ watch(
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;
@@ -342,157 +257,35 @@ watch(
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;
/* ── Mobile: compact mode ── */
@media (max-width: 768px) {
.chat-panel {
min-height: 280px;
max-height: 360px;
border-radius: 12px;
}
.chat-header {
padding: 10px 12px;
}
.chat-header h2 {
font-size: 11px;
}
.chat-messages {
padding: 8px 12px;
}
.chat-input-row {
padding: 8px 10px;
gap: 4px;
}
.focus-bar {
padding: 6px 12px;
}
.focus-label {
font-size: 7px;
}
.focus-text {
font-size: 9px;
}
}
</style>
@@ -10,6 +10,8 @@ import {
Zap,
} from '@lucide/vue'
import type { QueueItem } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
const props = defineProps<{
items: QueueItem[]
@@ -73,16 +75,18 @@ function onDragEnd(): void {
<template>
<div class="queue-panel">
<div class="queue-header" @click="expanded = !expanded">
<div class="queue-header" @click="expanded = !expanded" role="button" tabindex="0" :aria-expanded="expanded" @keyup.enter="expanded = !expanded">
<div class="queue-header-left">
<ListTodo :size="14" class="queue-icon" />
<ListTodo :size="14" class="text-[#a78bfa]" />
<h2>Chat Queue</h2>
<span class="queue-count">{{ items.length }}</span>
<Badge variant="outline" class="text-[10px] font-bold text-[#a78bfa] bg-[rgba(167,139,250,0.1)] border-0 rounded-full px-2">
{{ items.length }}
</Badge>
</div>
<button class="queue-toggle" aria-label="Toggle">
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="expanded ? 'Collapse' : 'Expand'">
<ChevronUp v-if="expanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
</Button>
</div>
<Transition name="queue-expand">
@@ -103,8 +107,9 @@ function onDragEnd(): void {
>
<div class="queue-item-body">
<div class="queue-item-head">
<span
class="priority-badge"
<Badge
variant="outline"
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
:style="{
color: priorityColor[item.priority],
borderColor: `${priorityColor[item.priority]}30`,
@@ -112,43 +117,25 @@ function onDragEnd(): void {
}"
>
{{ item.priority }}
</span>
</Badge>
<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)"
>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" 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)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" 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)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" 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)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)]" title="Remove" @click.stop="emit('remove', item.id)">
<Trash2 :size="12" />
</button>
</Button>
</div>
</div>
@@ -189,40 +176,12 @@ function onDragEnd(): void {
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;
@@ -263,15 +222,6 @@ function onDragEnd(): void {
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;
@@ -296,30 +246,6 @@ function onDragEnd(): void {
.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;
+12 -58
View File
@@ -2,6 +2,8 @@
import { ref } from 'vue'
import { Plus, Circle, ChevronRight } from '@lucide/vue'
import type { OpenTask } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
defineProps<{
tasks: OpenTask[]
@@ -23,10 +25,10 @@ function toggleExpand(id: string) {
<div class="task-card-panel">
<div class="task-header">
<h2 class="task-title">Offene Aufgaben</h2>
<button class="new-task-btn" @click="emit('newTask')">
<Button variant="outline" size="sm" class="h-7 text-[9px] gap-1 border-[rgba(139,124,246,0.2)] bg-[rgba(139,124,246,0.12)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.2)]" @click="emit('newTask')">
<Plus :size="12" />
<span>New Task</span>
</button>
</Button>
</div>
<div class="task-list">
@@ -54,12 +56,14 @@ function toggleExpand(id: string) {
<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'"
<Badge
variant="outline"
:class="task.source === 'iris'
? 'bg-[rgba(167,139,250,0.15)] text-[#a78bfa] border-0 text-[8px] py-0 px-1.5'
: 'bg-[rgba(59,130,246,0.15)] text-[#3b82f6] border-0 text-[8px] py-0 px-1.5'"
>
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
</span>
</Badge>
</div>
</div>
<div v-if="expandedId === task.id" class="task-detail">
@@ -69,10 +73,10 @@ function toggleExpand(id: string) {
</TransitionGroup>
</div>
<button class="task-board-btn" @click="emit('go-board')">
<Button variant="ghost" class="w-full mt-3 h-9 gap-1.5 text-[10px] border border-[rgba(139,124,246,0.15)] bg-[rgba(139,124,246,0.08)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.15)]" @click="emit('go-board')">
<span>Zum Task Board</span>
<ChevronRight :size="14" />
</button>
</Button>
</div>
</template>
@@ -105,24 +109,6 @@ function toggleExpand(id: string) {
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;
@@ -191,24 +177,6 @@ function toggleExpand(id: string) {
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;
@@ -229,20 +197,6 @@ function toggleExpand(id: string) {
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;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, toRef } from 'vue'
import { ref, computed, toRef, onMounted, onUnmounted } from 'vue'
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
@@ -55,6 +55,40 @@ function formatRuntime(seconds: number): string {
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ── Model formatter ──
function formatModel(model: string): string {
const parts = model.split('/')
const name = parts[parts.length - 1]
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
// ── Mobile media query ──
const isMobile = ref(false)
let mq: MediaQueryList | null = null
function onMqChange(e: MediaQueryListEvent) {
isMobile.value = e.matches
}
onMounted(() => {
mq = window.matchMedia('(max-width: 600px)')
isMobile.value = mq.matches
mq.addEventListener('change', onMqChange)
})
onUnmounted(() => {
if (mq) {
mq.removeEventListener('change', onMqChange)
}
})
function visibleTags(tags: string[]) {
if (!isMobile.value || tags.length <= 4) {
return { shown: tags, overflow: 0 }
}
return { shown: tags.slice(0, 4), overflow: tags.length - 4 }
}
// ── Hero computed ──
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
</script>
@@ -167,14 +201,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
<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>
<span class="node-task-dot" :style="{ color: hero.color }"></span>
{{ hero.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
<span v-if="hero.model" class="node-model">{{ hero.model }}</span>
<span v-if="hero.model" class="node-model">{{ formatModel(hero.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</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>
<template v-for="(tag, idx) in visibleTags(hero.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(hero.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${hero.color}18`, color: hero.color }">+{{ visibleTags(hero.tags).overflow }}</span>
</div>
</div>
</div>
@@ -215,14 +255,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
<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>
<span class="node-task-dot" :style="{ color: agent.color }"></span>
{{ agent.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
<span v-if="agent.model" class="node-model">{{ agent.model }}</span>
<span v-if="agent.model" class="node-model">{{ formatModel(agent.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</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>
<template v-for="(tag, idx) in visibleTags(agent.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(agent.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${agent.color}18`, color: agent.color }">+{{ visibleTags(agent.tags).overflow }}</span>
</div>
</div>
</div>
@@ -393,6 +439,20 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
margin-left: 6px;
}
/* ── Idle Row ── */
.idle-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.idle-badge {
font-size: 9px;
color: #6b7385;
padding: 2px 8px;
border-radius: 4px;
background: rgba(107, 115, 133, 0.15);
}
/* ── Tags ── */
.card-tags {
display: flex;
@@ -407,6 +467,9 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
border-radius: 5px;
letter-spacing: 0.02em;
}
.tag-overflow {
opacity: 0.7;
}
/* ── Hover Arrow ── */
.card-arrow {
@@ -428,12 +491,51 @@ const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? pro
display: block;
}
@media (max-width: 720px) {
/* ── Tablet ── */
@media (max-width: 900px) {
.agent-grid {
max-width: 100%;
gap: 12px;
}
.hero-slot {
max-width: 100%;
}
.card-name {
font-size: 13px;
}
.card-desc {
font-size: 9.5px;
}
}
/* ── Mobile ── */
@media (max-width: 600px) {
.agent-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.agent-card {
padding: 12px;
}
.card-icon-wrap {
width: 34px;
height: 34px;
}
.card-name {
font-size: 12px;
}
.card-role-tag {
font-size: 7.5px;
}
.card-desc {
font-size: 9px;
}
.card-tag {
font-size: 8px;
padding: 1px 6px;
}
.cards-layer {
gap: 20px;
gap: 24px;
}
}
</style>
+6 -6
View File
@@ -36,8 +36,8 @@ defineEmits<{
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--line, #1f2330);
background: var(--panel, #11141b);
border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--nx-panel, #11141b);
}
.mobile-menu { display: none; }
.search {
@@ -46,9 +46,9 @@ defineEmits<{
gap: 8px;
flex: 1;
padding: 6px 12px;
border: 1px solid var(--line, #1f2330);
border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px;
color: var(--text-dim, #6f7889);
color: var(--nx-text-dim, #6f7889);
font-size: 11px;
}
.search kbd {
@@ -82,13 +82,13 @@ defineEmits<{
padding: 5px 10px;
border: none;
border-radius: 6px;
background: var(--accent, #7b6ef2);
background: var(--nx-accent, #7b6ef2);
color: #fff;
font-size: 10px;
cursor: pointer;
}
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line, #1f2330); border-radius: 6px; background: transparent; color: var(--accent, #7b6ef2); cursor: pointer; }
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
}
</style>
@@ -154,9 +154,9 @@ async function logout() {
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--line, #1f2330);
background: var(--nx-line, #1f2330);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line, #1f2330); }
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
.sidebar-bottom > button {
display: flex;
align-items: center;
@@ -171,8 +171,8 @@ async function logout() {
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
.owner {
display: flex;
align-items: center;
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
interface Props {
variant?: NonNullable<VariantProps<typeof badgeVariants>['variant']>
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
})
</script>
<template>
<span :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</span>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('font-semibold leading-none tracking-tight', props.class)">
<slot />
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
open?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
open: false,
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const visible = ref(props.open)
watch(
() => props.open,
(val) => {
visible.value = val
},
)
function close() {
emit('update:open', false)
}
function onOverlayClick() {
close()
}
defineExpose({ close })
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="onOverlayClick"
>
<!-- Overlay -->
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<!-- Content -->
<div
:class="
cn(
'relative z-50 w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
props.class,
)
"
>
<slot :close="close" />
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('flex flex-col space-y-1.5 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<h2 :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)">
<slot />
</h2>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<input
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
v-bind="$attrs"
/>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
modelValue?: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<select
:value="modelValue"
:disabled="disabled"
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<slot />
</select>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<textarea
:class="
cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
v-bind="$attrs"
/>
</template>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { CheckCircle, XCircle, Info, X } from '@lucide/vue'
import { useToast } from '../../composables/useToast'
const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: {
icon: CheckCircle,
color: '#22c55e',
bg: 'rgba(34, 197, 94, 0.10)',
},
error: {
icon: XCircle,
color: '#ef4444',
bg: 'rgba(239, 68, 68, 0.10)',
},
info: {
icon: Info,
color: '#3b82f6',
bg: 'rgba(59, 130, 246, 0.10)',
},
}
</script>
<template>
<Teleport to="body">
<div class="toast-container" role="status" aria-live="polite">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-item"
:style="{
'--toast-color': typeConfig[toast.type].color,
'--toast-bg': typeConfig[toast.type].bg,
}"
>
<div class="toast-icon-wrap">
<component :is="typeConfig[toast.type].icon" :size="18" />
</div>
<span class="toast-message">{{ toast.message }}</span>
<button class="toast-close" @click="remove(toast.id)" aria-label="Dismiss">
<X :size="14" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
pointer-events: none;
}
.toast-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px 12px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--toast-color) 25%, transparent);
background: rgba(17, 20, 27, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto;
color: #e8eaf0;
font-size: 12.5px;
line-height: 1.4;
}
.toast-icon-wrap {
flex-shrink: 0;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--toast-bg);
color: var(--toast-color);
}
.toast-message {
flex: 1;
min-width: 0;
}
.toast-close {
flex-shrink: 0;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
}
.toast-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
}
/* Transition animations */
.toast-enter-active {
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast-leave-active {
transition: all 0.25s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(60px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(60px) scale(0.92);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
import type { ButtonProps } from '.'
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'default',
size: 'default',
disabled: false,
type: 'button',
})
const classes = computed(() =>
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
)
</script>
<template>
<button :type="type" :disabled="disabled" :class="classes">
<slot />
</button>
</template>
@@ -0,0 +1,42 @@
import type { HTMLAttributes } from 'vue'
import { type VariantProps, cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
export interface ButtonProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}
+73 -44
View File
@@ -74,6 +74,8 @@ interface DashboardAgentInfo {
model: string
isActive: boolean
currentTask: string
description?: string
tags?: string[]
}
interface DashboardOperationEntry {
@@ -105,65 +107,88 @@ interface DashboardQueueItem {
const AGENT_CATALOG: Record<string, Partial<AgentNodeData>> = {
iris: {
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
tags: ['Orchestration', 'Delegation', 'Approval'],
description: 'Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.',
tags: ['Orchestration', 'Delegation', 'Approval', 'Risk Management'],
color: '#8b7cf6',
icon: 'bot',
hero: true,
goal: 'Complete Mission Control v3',
goal: 'Mission Control — maximale Autonomie bei kontrolliertem Risiko',
progress: 90,
workload: 60,
workingFeed: [],
thinkingStream: [],
},
developer: {
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
color: '#3b82f6',
icon: 'code',
goal: 'Nexus Dashboard & Dungeon System',
progress: 70,
workload: 65,
workingFeed: [],
thinkingStream: [],
},
architekt: {
description: 'Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.',
tags: ['Docker', 'Nginx', 'CI/CD', 'Firewall', 'VPS'],
color: '#eab308',
icon: 'server',
goal: 'Stabile Zero-Downtime-Deployments',
progress: 60,
workload: 45,
workingFeed: [],
thinkingStream: [],
},
researcher: {
description: 'Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.',
tags: ['Research', 'Quellenprüfung', 'Analyse', 'Docs'],
color: '#22c55e',
icon: 'search',
goal: 'Verifizierte, strukturierte Recherche-Ergebnisse',
progress: 40,
workload: 30,
workingFeed: [],
thinkingStream: [],
},
reviewer: {
description: 'Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.',
tags: ['Code Review', 'Testing', 'Security', 'Quality'],
color: '#a855f7',
icon: 'shield',
goal: 'Zero critical findings before merge',
progress: 85,
workload: 55,
workingFeed: [],
thinkingStream: [],
},
developer: {
description: 'Implements features across the stack with TypeScript, C#, and Vue.',
tags: ['Coding', 'Development', 'Builds'],
executor: {
description: 'Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.',
tags: ['Docker', 'Shell', 'Host', 'Deployment'],
color: '#f59e0b',
icon: 'server',
goal: 'Sichere Host-Execution im Allowlist-Rahmen',
progress: 95,
workload: 20,
workingFeed: [],
thinkingStream: [],
},
// Alias: API sends "programmer" but AGENT_CATALOG uses "developer" as canonical key
programmer: {
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
color: '#3b82f6',
icon: 'code',
goal: 'Complete Dungeon CRUD + room generation',
progress: 62,
goal: 'Nexus Dashboard & Dungeon System',
progress: 70,
workload: 65,
workingFeed: [],
thinkingStream: [],
},
devops: {
description: 'Manages Docker, deployment pipelines, and system reliability.',
tags: ['Deployment', 'Docker', 'CI/CD'],
color: '#eab308',
icon: 'server',
goal: 'Reduce build times by 40%',
progress: 45,
workload: 40,
workingFeed: [],
thinkingStream: [],
},
researcher: {
description: 'Researches APIs, patterns, and best practices. Maintains docs.',
tags: ['Research', 'Analysis', 'Docs'],
color: '#22c55e',
icon: 'search',
goal: 'Recommend real-time communication strategy',
progress: 30,
workload: 25,
workingFeed: [],
thinkingStream: [],
},
reviewer: {
description: 'Reviews pull requests, enforces standards, runs test suites.',
tags: ['Code Review', 'Testing', 'Quality'],
color: '#a855f7',
icon: 'shield',
goal: 'Zero critical findings before merge',
progress: 80,
workload: 50,
workingFeed: [],
thinkingStream: [],
},
}
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['developer']
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']
return {
id: api.id,
name: api.name,
@@ -171,8 +196,8 @@ function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
model: api.model,
currentTask: api.currentTask ?? 'Idle',
active: api.isActive,
description: catalog.description ?? '',
tags: catalog.tags ?? [],
description: api.description ?? catalog.description ?? '',
tags: api.tags ?? catalog.tags ?? [],
color: catalog.color ?? '#6b7385',
icon: catalog.icon ?? 'bot',
hero: catalog.hero ?? false,
@@ -214,6 +239,7 @@ const agents = ref<AgentNodeData[]>([])
const chatMessages = ref<ChatMessage[]>([])
const irisBusy = ref(false)
const irisFocus = ref('')
const busySince = ref(0)
// Operations Feed
const feedEntries = ref<FeedEntry[]>([])
@@ -330,6 +356,7 @@ async function sendChatMessage(text: string): Promise<void> {
})
irisBusy.value = true
busySince.value = Date.now()
try {
const res = await apiFetch('/api/dashboard/chat/send', {
@@ -362,6 +389,7 @@ async function sendChatMessage(text: string): Promise<void> {
})
} finally {
irisBusy.value = false
busySince.value = 0
irisFocus.value = text.trim()
}
}
@@ -457,6 +485,7 @@ export function useDashboardData() {
chatMessages,
irisBusy,
irisFocus,
busySince,
irisRuntime,
queue,
gatewayOk,
-37
View File
@@ -1,37 +0,0 @@
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 }
}
+28
View File
@@ -0,0 +1,28 @@
import { ref, readonly } from 'vue'
import type { Toast } from '../types/toast'
let nextId = 1
const toasts = ref<Toast[]>([])
export function useToast() {
function add(message: string, type: Toast['type'] = 'info', durationMs = 3500) {
const id = nextId++
toasts.value.push({ id, message, type, durationMs })
if (durationMs > 0) {
setTimeout(() => remove(id), durationMs)
}
}
function remove(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return {
toasts: readonly(toasts),
success: (msg: string, durationMs?: number) => add(msg, 'success', durationMs),
error: (msg: string, durationMs?: number) => add(msg, 'error', durationMs),
info: (msg: string, durationMs?: number) => add(msg, 'info', durationMs),
remove,
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import './style.css'
import './assets/main.css'
const pinia = createPinia()
+38 -38
View File
@@ -5,13 +5,13 @@
color: #e8eaf0;
background: #080a0f;
font-synthesis: none;
--panel: #10131a;
--panel-soft: #0d1016;
--line: #202530;
--muted: #7e8799;
--accent: #8b7cf6;
--accent-soft: rgba(139, 124, 246, .12);
--green: #51d49a;
--nx-panel: #10131a;
--nx-panel-soft: #0d1016;
--nx-line: #202530;
--nx-muted: #7e8799;
--nx-accent: #8b7cf6;
--nx-accent-soft: rgba(139, 124, 246, .12);
--nx-green: #51d49a;
}
* { box-sizing: border-box; }
@@ -22,11 +22,11 @@ button { color: inherit; font: inherit; }
.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; }
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #443d7c; border-radius: 10px; background: linear-gradient(145deg, #241f44, #12121f); color: #b8adff; box-shadow: 0 0 24px rgba(139,124,246,.13); }
.brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
.brand span, .owner span { display: block; color: var(--muted); font-size: 10px; margin-top: 2px; }
.brand span, .owner span { display: block; color: var(--nx-muted); font-size: 10px; margin-top: 2px; }
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav button, .sidebar-bottom > button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; padding: 9px 10px; border-radius: 7px; background: transparent; color: #8991a1; font-size: 12px; text-align: left; cursor: pointer; }
.nav button:hover, .nav button.active { color: #ececf5; background: var(--accent-soft); }
.nav button.active { box-shadow: inset 2px 0 var(--accent); }
.nav button:hover, .nav button.active { color: #ececf5; background: var(--nx-accent-soft); }
.nav button.active { box-shadow: inset 2px 0 var(--nx-accent); }
.nav button i { margin-left: auto; padding: 1px 6px; border: 1px solid #343947; border-radius: 8px; font-size: 9px; font-style: normal; }
.sidebar-bottom { margin-top: auto; border-top: 1px solid #1b1f28; padding-top: 10px; }
.owner { display: grid; grid-template-columns: 31px 1fr auto; gap: 9px; align-items: center; margin-top: 10px; padding: 10px 8px; }
@@ -38,17 +38,17 @@ main { min-width: 0; }
.search kbd { margin-left: auto; padding: 2px 5px; border: 1px solid #2c313d; border-radius: 4px; color: #606979; font-size: 9px; }
.top-actions { display: flex; align-items: center; gap: 10px; }
.connection { display: flex; gap: 6px; align-items: center; font-size: 10px; color: #8c95a5; }
.connection.live { color: var(--green); }
.connection.live { color: var(--nx-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 { 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; }
.page-heading p, .placeholder p { margin: 0; color: var(--muted); font-size: 11px; }
.refresh { border-color: var(--line); background: var(--panel); color: #a5adba; }
.page-heading p, .placeholder p { margin: 0; color: var(--nx-muted); font-size: 11px; }
.refresh { border-color: var(--nx-line); background: var(--nx-panel); color: #a5adba; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
.metrics article, .panel { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
.metrics article, .panel { border: 1px solid var(--nx-line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
.metrics article { padding: 16px 17px; }
.metrics span { color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; }
.metrics strong { display: block; margin: 7px 0 5px; font-size: 24px; letter-spacing: -.04em; }
@@ -61,29 +61,29 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.panel-head h2 { margin: 4px 0 0; font-size: 13px; }
.panel-head button { border: 0; background: transparent; color: #8e96a5; font-size: 9px; }
.badge { padding: 4px 8px; border-radius: 10px; font-size: 8px; }
.badge.positive { color: var(--green); background: rgba(81,212,154,.1); }
.badge.positive { color: var(--nx-green); background: rgba(81,212,154,.1); }
.badge.warning { color: #e7b660; background: rgba(231,182,96,.1); }
.runtime-row { display: flex; align-items: center; gap: 12px; padding-top: 22px; }
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--accent-soft); }
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--nx-accent-soft); }
.runtime-main strong, .model strong, .project strong, .event strong { display: block; font-size: 11px; }
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--nx-muted); font-size: 9px; }
.pulse-bars { height: 42px; display: flex; align-items: center; gap: 3px; margin-left: auto; }
.pulse-bars i { width: 3px; min-height: 5px; border-radius: 3px; background: linear-gradient(#927fff, #443b7c); }
.model { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 9px; padding: 12px 2px; border-bottom: 1px solid #1b2029; }
.model > span:last-child { color: #687181; font-size: 8px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #657083; }
.status-dot.online { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.status-dot.online { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.status-dot.offline { background: #e16e75; }
.project { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
.project-letter { width: 31px; height: 31px; display: grid; place-items: center; border: 1px solid #353047; border-radius: 7px; color: #a99cf5; font-size: 10px; }
.project-info > div:first-child { display: flex; justify-content: space-between; }
.project-info span { color: var(--muted); font-size: 8px; }
.project-info span { color: var(--nx-muted); font-size: 8px; }
.project b { color: #838c9c; font-size: 9px; }
.progress { height: 3px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #242936; }
.progress i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #685ac8, #a091ff); }
.event { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
.event > span { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #657083; }
.event > span.runtime { background: var(--green); }
.event > span.runtime { background: var(--nx-green); }
.event > span.deploy { background: #8b7cf6; }
.event > span.security { background: #e5ad52; }
.placeholder { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
@@ -117,25 +117,25 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.module-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
.module-card h3, .model-detail h3, .timeline h3, .chat-shell h3 { margin: 8px 0 4px; font-size: 13px; }
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--nx-muted); font-size: 10px; line-height: 1.5; }
.module-card-head, .project-card footer { display: flex; align-items: center; justify-content: space-between; }
.project-card .progress { margin: 34px 0 12px; }
.project-card footer { color: var(--muted); font-size: 9px; }
.project-card footer { color: var(--nx-muted); font-size: 9px; }
.kanban { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-items: start; }
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: rgba(14,17,23,.8); }
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--nx-line); border-radius: 9px; background: rgba(14,17,23,.8); }
.kanban-column > header { display: flex; justify-content: space-between; padding: 5px 3px 14px; color: #aeb4c0; font-size: 10px; }
.kanban-column > header b { padding: 1px 6px; border-radius: 8px; background: #242936; }
.task-card { margin-bottom: 9px; padding: 14px; border: 1px solid #252a35; border-radius: 8px; background: #12151c; }
.task-card h3 { margin: 10px 0 22px; font-size: 11px; }
.task-card select { width: 100%; margin-bottom: 12px; padding: 7px 8px; border: 1px solid #292f3b; border-radius: 7px; outline: none; color: #c8ccd5; background: #0c0f14; font: inherit; font-size: 9px; cursor: pointer; }
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--muted); font-size: 8px; }
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--nx-muted); font-size: 8px; }
.priority { font-size: 8px; text-transform: uppercase; color: #9b91e6; }.priority.critical { color: #ec7b82; }.priority.high { color: #e5b05e; }
.empty-state { padding: 35px 0; text-align: center; color: #596171; font-size: 9px; }
.agent-card { display: grid; grid-template-columns: auto 1fr auto; gap: 13px; align-items: start; }
.agent-avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; color: #66d5a4; background: rgba(81,212,154,.1); }
.agent-avatar.violet { color: #a99cff; background: var(--accent-soft); }
.agent-avatar.violet { color: #a99cff; background: var(--nx-accent-soft); }
.module-list { padding: 4px 18px; }
.model-detail { display: grid; grid-template-columns: 45px 1fr auto; align-items: center; gap: 14px; padding: 19px 0; border-bottom: 1px solid #1d222c; }
.route-rank { color: #6f63c9; font-size: 12px; font-weight: 700; }
@@ -143,10 +143,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.timeline article { display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #1d222c; }
.timeline-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #70d8aa; background: rgba(81,212,154,.1); }.timeline-icon.security { color: #e5b05e; background: rgba(229,176,94,.1); }
.chat-shell { max-width: 760px; margin: auto; padding: 0; overflow: hidden; }
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--line); }
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--nx-line); }
.messages { min-height: 360px; padding: 22px; }
.message { max-width: 75%; margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: #171b23; }.message.owner { margin-left: auto; background: #211d39; }.message strong { font-size: 9px; color: #9d91eb; }
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--line); }
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--nx-line); }
.chat-shell input { min-width: 0; padding: 11px 13px; border: 1px solid #292f3b; border-radius: 8px; outline: none; color: #e7e9ef; background: #0c0f14; font: inherit; font-size: 10px; }
.chat-shell form button { width: 38px; border: 1px solid #443d7c; border-radius: 8px; color: #beb4ff; background: #211d39; }
@media (max-width: 900px) { .module-grid, .kanban { grid-template-columns: 1fr; } }
@@ -159,20 +159,20 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.settings-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.settings-grid .module-card { min-height: 165px; }
.settings-grid .badge { display: inline-block; margin-top: 28px; }
.sidebar-bottom > button.active { color: #ececf5; background: var(--accent-soft); }
.sidebar-bottom > button.active { color: #ececf5; background: var(--nx-accent-soft); }
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
.owner { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.owner:hover { background: var(--accent-soft); border-radius: 8px; }
.owner:hover { background: var(--nx-accent-soft); border-radius: 8px; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--nx-line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
.login-brand { display: flex; align-items: center; gap: 12px; padding-bottom: 28px; border-bottom: 1px solid #1d222c; }
.login-brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
.login-brand span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
.login-brand span { display: block; margin-top: 3px; color: var(--nx-muted); font-size: 10px; }
.login-heading { padding: 28px 0 20px; }
.login-heading h1 { margin-top: 9px; font-size: 25px; }
.login-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
.login-heading p { margin: 0; color: var(--nx-muted); font-size: 11px; line-height: 1.6; }
.login-card form { display: grid; gap: 14px; }
.login-card label span { display: block; margin-bottom: 7px; color: #aab1bf; font-size: 10px; }
.login-card input { width: 100%; padding: 12px 13px; border: 1px solid #2a303b; border-radius: 8px; outline: none; color: #eef0f5; background: #0a0d12; font: inherit; font-size: 12px; }
@@ -184,10 +184,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
/* Project health row */
.project-health-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
.health-card { padding: 12px 16px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
.health-card { padding: 12px 16px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
.health-label { display: block; color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
.health-card strong { display: block; margin-top: 6px; font-size: 22px; letter-spacing: -.04em; }
.health-online { color: var(--green); }
.health-online { color: var(--nx-green); }
.health-degraded { color: #e7b660; }
.health-offline { color: #e16e75; }
.health-unknown { color: #7e8799; }
@@ -195,9 +195,9 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
/* Runtime health indicators */
.runtime-health-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.runtime-health-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.runtime-health-dot.healthy { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.runtime-health-dot.healthy { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.runtime-health-dot.unhealthy { background: #e7b660; box-shadow: 0 0 7px rgba(231,182,96,.3); }
.runtime-health-text { font-size: 9px; color: var(--muted); }
.runtime-health-text { font-size: 9px; color: var(--nx-muted); }
.runtime-incident { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.incident-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid rgba(229,176,94,.3); border-radius: 6px; background: rgba(229,176,94,.08); color: #e5b05e; font-size: 9px; }
@@ -209,7 +209,7 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.snapshot-agent-item .name { font-size: 10px; color: #e8eaf0; }
.snapshot-agent-item .role-tag { margin-left: auto; font-size: 8px; padding: 1px 6px; border: 1px solid #343947; border-radius: 6px; color: #8991a1; }
.status-dot--sm { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.status-dot--sm.online { background: var(--green); }
.status-dot--sm.online { background: var(--nx-green); }
.status-dot--sm.degraded { background: #e7b660; }
.status-dot--sm.offline { background: #e16e75; }
+6
View File
@@ -0,0 +1,6 @@
export interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
durationMs: number
}
+42 -6
View File
@@ -101,7 +101,9 @@ function onQueueExecuteNow(id: string): void {
<style scoped>
.dashboard {
display: grid; grid-template-columns: 280px 1fr 320px; gap: 14px;
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 14px;
height: 100%; min-height: 0;
animation: fade-in 0.35s ease-out;
}
@@ -118,7 +120,7 @@ function onQueueExecuteNow(id: string): void {
/* Quote Pill */
.quote-pill {
background: var(--panel);
background: var(--nx-panel);
border: 1px solid rgba(139, 124, 246, 0.25);
border-radius: 14px;
padding: 14px 22px;
@@ -163,8 +165,8 @@ function onQueueExecuteNow(id: string): void {
justify-content: center;
gap: 24px;
padding: 12px 20px;
background: var(--panel);
border: 1px solid var(--line);
background: var(--nx-panel);
border: 1px solid var(--nx-line);
border-radius: 10px;
}
.legend-item {
@@ -200,8 +202,42 @@ function onQueueExecuteNow(id: string): void {
.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; }
/* Tablet: 2 columns — left+center together, right column alongside */
@media (max-width: 1100px) {
.dashboard { grid-template-columns: 1fr; }
.col-left, .col-center, .col-right { overflow: visible; padding: 0; }
.dashboard {
grid-template-columns: 1fr 320px;
grid-template-rows: auto auto;
}
.col-left {
grid-column: 1;
grid-row: 1;
}
.col-center {
grid-column: 1;
grid-row: 2;
}
.col-right {
grid-column: 2;
grid-row: 1 / 3;
}
}
/* Mobile: 1 column, everything stacked */
@media (max-width: 768px) {
.dashboard {
grid-template-columns: 1fr;
gap: 8px;
}
.col-left, .col-center, .col-right {
grid-column: 1;
grid-row: auto;
overflow: visible;
padding: 0;
}
.quote-pill { padding: 10px 14px; }
.quote-text { font-size: 10px; }
.team-title { font-size: 20px; }
.legend-row { gap: 12px; padding: 8px 12px; flex-wrap: wrap; }
.legend-item { font-size: 8px; gap: 4px; }
}
</style>
+2 -2
View File
@@ -439,7 +439,7 @@ onMounted(loadDocs)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -461,7 +461,7 @@ onMounted(loadDocs)
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
border-top: 1px solid var(--nx-line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
+2 -2
View File
@@ -410,7 +410,7 @@ onMounted(loadIncidents)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.incident-rendered :deep(pre code) {
@@ -432,7 +432,7 @@ onMounted(loadIncidents)
}
.incident-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
border-top: 1px solid var(--nx-line);
margin: 1.2em 0;
}
.incident-rendered :deep(strong) {
+2 -2
View File
@@ -417,7 +417,7 @@ onMounted(loadMemories)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -439,7 +439,7 @@ onMounted(loadMemories)
}
.memory-rendered :deep(hr) {
border: none;
border-top: 1px solid var(--line);
border-top: 1px solid var(--nx-line);
margin: 1.2em 0;
}
.memory-rendered :deep(strong) {
+5 -5
View File
@@ -282,8 +282,8 @@ onMounted(loadProject)
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--accent-soft);
color: var(--accent);
background: var(--nx-accent-soft);
color: var(--nx-accent);
}
.btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
@@ -321,7 +321,7 @@ onMounted(loadProject)
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 6px;
@@ -361,7 +361,7 @@ onMounted(loadProject)
.progress-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
border-radius: 4px;
transition: width 0.3s;
}
@@ -403,7 +403,7 @@ onMounted(loadProject)
.task-icon.done { color: rgb(34, 197, 94); }
.task-icon.blocked { color: rgb(239, 68, 68); }
.task-icon.backlog { color: var(--text-muted); }
.task-icon.in-progress { color: var(--accent); }
.task-icon.in-progress { color: var(--nx-accent); }
.task-info {
flex: 1;
}
+3 -3
View File
@@ -231,7 +231,7 @@ async function changePassword() {
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 8px;
@@ -267,8 +267,8 @@ async function changePassword() {
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--accent-soft);
color: var(--accent);
background: var(--nx-accent-soft);
color: var(--nx-accent);
border-radius: 4px;
font-size: 0.8rem;
text-transform: uppercase;