feat: complete Nexus mission-control workflows
This commit is contained in:
@@ -4,7 +4,8 @@ import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, C
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { TASK_STATES } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { useOperationsStore } from '../stores/operations'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
|
||||
|
||||
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -13,8 +14,13 @@ const emit = defineEmits<{
|
||||
updateTaskState: [id: string, state: string]
|
||||
}>()
|
||||
const store = useOperationsStore()
|
||||
const auth = useAuthStore()
|
||||
const agents = ref<AgentInfo[]>([])
|
||||
const agentsLoading = ref(false)
|
||||
const pendingApprovals = ref<PendingApprovalTask[]>([])
|
||||
const pendingApprovalsLoading = ref(false)
|
||||
const pendingApprovalsError = ref('')
|
||||
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
|
||||
|
||||
async function loadAgents() {
|
||||
if (agentsLoading.value) return
|
||||
@@ -23,12 +29,43 @@ async function loadAgents() {
|
||||
agentsLoading.value = false
|
||||
}
|
||||
|
||||
async function loadPendingApprovals() {
|
||||
if (!canModerateApprovals.value) {
|
||||
pendingApprovals.value = []
|
||||
pendingApprovalsError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
pendingApprovalsLoading.value = true
|
||||
pendingApprovalsError.value = ''
|
||||
try {
|
||||
pendingApprovals.value = await store.fetchPendingApprovals()
|
||||
} catch (e) {
|
||||
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
|
||||
} finally {
|
||||
pendingApprovalsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.view === 'Agents') loadAgents()
|
||||
if (props.view === 'Task Board') void loadPendingApprovals()
|
||||
})
|
||||
|
||||
watch(() => props.view, (v) => {
|
||||
if (v === 'Agents') loadAgents()
|
||||
if (v === 'Task Board') void loadPendingApprovals()
|
||||
})
|
||||
|
||||
watch(canModerateApprovals, (value) => {
|
||||
if (props.view !== 'Task Board') return
|
||||
if (value) {
|
||||
void loadPendingApprovals()
|
||||
return
|
||||
}
|
||||
|
||||
pendingApprovals.value = []
|
||||
pendingApprovalsError.value = ''
|
||||
})
|
||||
|
||||
const newProject = ref('')
|
||||
@@ -51,6 +88,7 @@ async function handleApproveTask(id: string) {
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.approveTask(id)
|
||||
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
||||
} finally {
|
||||
@@ -63,6 +101,7 @@ async function handleRejectTask(id: string) {
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.rejectTask(id)
|
||||
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
||||
} finally {
|
||||
@@ -187,6 +226,31 @@ async function sendMessage() {
|
||||
</div>
|
||||
|
||||
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
||||
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
|
||||
<header class="approval-strip-head">
|
||||
<div>
|
||||
<span class="kicker">Owner approvals</span>
|
||||
<h3>Pending approvals</h3>
|
||||
</div>
|
||||
<span class="badge">{{ pendingApprovals.length }}</span>
|
||||
</header>
|
||||
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue…</p>
|
||||
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
|
||||
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
|
||||
<div v-else class="approval-list">
|
||||
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
|
||||
<div>
|
||||
<strong>{{ task.title }}</strong>
|
||||
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
|
||||
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
|
||||
</section>
|
||||
<div v-if="view === 'Task Board'" class="kanban">
|
||||
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
||||
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
||||
@@ -214,7 +278,7 @@ async function sendMessage() {
|
||||
<div class="task-card-head">
|
||||
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
||||
<div class="task-card-actions">
|
||||
<template v-if="task.state === 'In progress'">
|
||||
<template v-if="task.state === 'In progress' && canModerateApprovals">
|
||||
<button
|
||||
class="task-approve-btn"
|
||||
title="Approve"
|
||||
@@ -338,6 +402,53 @@ async function sendMessage() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.approval-strip {
|
||||
margin: 0 0 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-radius: 14px;
|
||||
background: rgba(255,255,255,.025);
|
||||
}
|
||||
.approval-strip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.approval-strip-head h3 {
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.approval-strip-note {
|
||||
margin: 10px 0 0;
|
||||
color: #8e96a8;
|
||||
}
|
||||
.approval-strip-note.error {
|
||||
color: #e16e75;
|
||||
}
|
||||
.approval-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.approval-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 10, 18, .35);
|
||||
}
|
||||
.approval-card p {
|
||||
margin: 4px 0 0;
|
||||
color: #8e96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.task-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -10,6 +10,9 @@ defineProps<{
|
||||
saving: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
backupStatus: string
|
||||
reloadStatus: string
|
||||
reloadMessage: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -60,6 +63,12 @@ function onInput(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reloadMessage" class="editor-health">
|
||||
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
|
||||
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
|
||||
<span class="health-note">{{ reloadMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Text editor -->
|
||||
<textarea
|
||||
class="config-editor"
|
||||
@@ -89,6 +98,17 @@ function onInput(event: Event) {
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
gap: 12px;
|
||||
}
|
||||
.editor-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
background: rgba(255,255,255,.015);
|
||||
color: #8e96a8;
|
||||
font-size: 10.5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -165,6 +185,30 @@ function onInput(event: Event) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.health-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.health-pill.created {
|
||||
color: #51d49a;
|
||||
border-color: rgba(81,212,154,.3);
|
||||
}
|
||||
.health-pill.not_applicable {
|
||||
color: #d4b26a;
|
||||
border-color: rgba(212,178,106,.25);
|
||||
}
|
||||
.health-pill.not_supported {
|
||||
color: #9aa4bb;
|
||||
border-color: rgba(154,164,187,.25);
|
||||
}
|
||||
.health-note {
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
width: 100%;
|
||||
|
||||
@@ -2,6 +2,15 @@ import { defineStore } from 'pinia'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
export interface PendingApprovalTask {
|
||||
id: string
|
||||
title: string
|
||||
state: string
|
||||
priority: string
|
||||
projectId?: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const fallback: OperationsSnapshot = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
|
||||
@@ -22,6 +31,11 @@ export const useOperationsStore = defineStore('operations', {
|
||||
connected: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchPendingApprovals(): Promise<PendingApprovalTask[]> {
|
||||
const response = await apiFetch('/api/v1/tasks/pending-approval')
|
||||
if (!response.ok) throw new Error('Pending approvals could not be loaded')
|
||||
return await response.json()
|
||||
},
|
||||
async createProject(name: string) {
|
||||
const response = await apiFetch('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
@@ -145,7 +159,10 @@ export const useOperationsStore = defineStore('operations', {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be approved')
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Task could not be approved' }))
|
||||
throw new Error(err.detail || 'Task could not be approved')
|
||||
}
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks.splice(index, 1)
|
||||
@@ -161,7 +178,10 @@ export const useOperationsStore = defineStore('operations', {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be rejected')
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Task could not be rejected' }))
|
||||
throw new Error(err.detail || 'Task could not be rejected')
|
||||
}
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity } from '@lucide/vue'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
import type { AgentDetail } from '../types'
|
||||
import ConfigTabs from '../components/config/ConfigTabs.vue'
|
||||
import ConfigEditor from '../components/config/ConfigEditor.vue'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -18,6 +19,19 @@ const configFiles = ref<ConfigFileInfo[]>([])
|
||||
const activeTab = ref(0)
|
||||
const configsLoading = ref(false)
|
||||
const configsError = ref('')
|
||||
const activityItems = ref<AgentActivityItem[]>([])
|
||||
const activityLoading = ref(false)
|
||||
const activityError = ref('')
|
||||
const summaryLoading = ref(false)
|
||||
const summaryError = ref('')
|
||||
const summary = ref<AgentSummary | null>(null)
|
||||
const liveConnected = ref(false)
|
||||
const liveUnavailable = ref(false)
|
||||
let liveAbort: AbortController | null = null
|
||||
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let liveStreamStopped = false
|
||||
let lastLiveSequence = 0
|
||||
|
||||
const initLoading = ref(true)
|
||||
|
||||
@@ -28,6 +42,9 @@ interface EditorState {
|
||||
dirty: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
backupStatus: string
|
||||
reloadStatus: string
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
interface ConfigFileInfo {
|
||||
@@ -40,6 +57,46 @@ interface ConfigFileDetail extends ConfigFileInfo {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface AgentActivityItem {
|
||||
id: number | null
|
||||
type: string
|
||||
message: string
|
||||
at: string
|
||||
source: string
|
||||
relativeTime?: string | null
|
||||
}
|
||||
|
||||
interface AgentSummary {
|
||||
now: AgentSummaryItem
|
||||
today: AgentSummaryItem
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
interface AgentSummaryItem {
|
||||
text: string
|
||||
source: string
|
||||
timestamp?: string | null
|
||||
}
|
||||
|
||||
interface SaveConfigResult {
|
||||
fileName: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
validation: {
|
||||
status: string
|
||||
fileKind: string
|
||||
errors: string[]
|
||||
}
|
||||
backup: {
|
||||
status: string
|
||||
backupCreated: boolean
|
||||
}
|
||||
reloadCheck: {
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
const editorState = ref<EditorState>({
|
||||
content: '',
|
||||
savedContent: '',
|
||||
@@ -47,6 +104,9 @@ const editorState = ref<EditorState>({
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
})
|
||||
|
||||
const agentId = route.params.id as string
|
||||
@@ -99,6 +159,22 @@ function formatLastSeen(dateStr?: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatActivityTime(item: AgentActivityItem): string {
|
||||
if (item.relativeTime) return item.relativeTime
|
||||
const d = new Date(item.at)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function activityTypeLabel(type: string): string {
|
||||
if (type === 'thinking') return 'Thinking'
|
||||
if (type === 'handoff') return 'Handoff'
|
||||
if (type === 'task') return 'Task'
|
||||
return 'Activity'
|
||||
}
|
||||
|
||||
async function loadAgent() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -113,6 +189,110 @@ async function loadAgent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
activityLoading.value = true
|
||||
activityError.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
|
||||
if (!response.ok) throw new Error('Failed to load activity')
|
||||
activityItems.value = await response.json()
|
||||
} catch (e) {
|
||||
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
|
||||
} finally {
|
||||
activityLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
summaryLoading.value = true
|
||||
summaryError.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
|
||||
if (!response.ok) throw new Error('Failed to load summary')
|
||||
summary.value = await response.json()
|
||||
} catch (e) {
|
||||
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleActivityReload() {
|
||||
if (activityReloadTimer) return
|
||||
activityReloadTimer = setTimeout(async () => {
|
||||
activityReloadTimer = null
|
||||
await loadActivity()
|
||||
await loadSummary()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function formatSummaryTimestamp(value?: string | null): string {
|
||||
if (!value) return 'No timestamp'
|
||||
const d = new Date(value)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function summarySourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'nexus-activity': return 'Nexus activity'
|
||||
case 'gateway-session-history': return 'Gateway history'
|
||||
case 'derived-mixed': return 'Derived from mixed feed'
|
||||
case 'none': return 'No data'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamReconnect() {
|
||||
if (liveStreamStopped || liveReconnectTimer) return
|
||||
liveReconnectTimer = setTimeout(() => {
|
||||
liveReconnectTimer = null
|
||||
void connectActivityStream()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
async function connectActivityStream() {
|
||||
liveAbort?.abort()
|
||||
liveAbort = new AbortController()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
const cursor = (data as any)?.cursor
|
||||
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
|
||||
|
||||
if (event === 'snapshot') {
|
||||
liveConnected.value = true
|
||||
liveUnavailable.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (event !== 'update') return
|
||||
const envelope = (data as any)?.envelope
|
||||
if (envelope?.type !== 'activity.created') return
|
||||
|
||||
const agentIds = Array.isArray(envelope?.payload?.agentIds)
|
||||
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
|
||||
: []
|
||||
|
||||
if (agentIds.includes(agentId.toLowerCase())) {
|
||||
scheduleActivityReload()
|
||||
}
|
||||
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
|
||||
|
||||
await stream.closed
|
||||
if (!liveStreamStopped) {
|
||||
liveConnected.value = false
|
||||
scheduleStreamReconnect()
|
||||
}
|
||||
} catch {
|
||||
liveConnected.value = false
|
||||
liveUnavailable.value = true
|
||||
if (!liveStreamStopped) scheduleStreamReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFiles() {
|
||||
configsLoading.value = true
|
||||
configsError.value = ''
|
||||
@@ -147,6 +327,9 @@ async function loadFileContent(fileName: string) {
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
}
|
||||
} catch (e) {
|
||||
editorState.value = {
|
||||
@@ -156,6 +339,9 @@ async function loadFileContent(fileName: string) {
|
||||
dirty: false,
|
||||
saveStatus: 'error',
|
||||
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +366,9 @@ async function saveFile() {
|
||||
editorState.value.saving = true
|
||||
editorState.value.saveStatus = 'idle'
|
||||
editorState.value.saveMessage = ''
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
editorState.value.reloadMessage = ''
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
|
||||
@@ -190,14 +379,21 @@ async function saveFile() {
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error((err as any).error || 'Failed to save file')
|
||||
const problem = err as { error?: string; errors?: Record<string, string[]> }
|
||||
const detail = problem.error
|
||||
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
||||
|| 'Failed to save file'
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const result: { fileName: string; size: number; modifiedAt: string } = await response.json()
|
||||
const result: SaveConfigResult = await response.json()
|
||||
editorState.value.savedContent = editorState.value.content
|
||||
editorState.value.dirty = false
|
||||
editorState.value.saveStatus = 'saved'
|
||||
editorState.value.saveMessage = 'Gespeichert'
|
||||
editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
|
||||
editorState.value.backupStatus = result.backup.status
|
||||
editorState.value.reloadStatus = result.reloadCheck.status
|
||||
editorState.value.reloadMessage = result.reloadCheck.message
|
||||
|
||||
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
|
||||
if (idx >= 0) {
|
||||
@@ -213,19 +409,33 @@ async function saveFile() {
|
||||
} catch (e) {
|
||||
editorState.value.saveStatus = 'error'
|
||||
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
} finally {
|
||||
editorState.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
liveStreamStopped = false
|
||||
initLoading.value = true
|
||||
await Promise.allSettled([
|
||||
loadAgent(),
|
||||
loadConfigFiles(),
|
||||
loadActivity(),
|
||||
loadSummary(),
|
||||
])
|
||||
connectActivityStream()
|
||||
initLoading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
liveStreamStopped = true
|
||||
liveAbort?.abort()
|
||||
liveAbort = null
|
||||
if (activityReloadTimer) clearTimeout(activityReloadTimer)
|
||||
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -269,6 +479,85 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="thinking-section">
|
||||
<header class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">LIVE</span>
|
||||
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
|
||||
<p class="section-note">
|
||||
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
|
||||
</p>
|
||||
</div>
|
||||
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
|
||||
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="summaryLoading && !summary" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading summaries...
|
||||
</div>
|
||||
|
||||
<div v-else-if="summaryError && !summary" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="summary" class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span>Now</span>
|
||||
<p>{{ summary.now.text }}</p>
|
||||
<small>{{ summarySourceLabel(summary.now.source) }} · {{ formatSummaryTimestamp(summary.now.timestamp) }}</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span>Today</span>
|
||||
<p>{{ summary.today.text }}</p>
|
||||
<small>{{ summarySourceLabel(summary.today.source) }} · {{ formatSummaryTimestamp(summary.today.timestamp) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No summary available.
|
||||
</div>
|
||||
|
||||
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
|
||||
<AlertCircle :size="14" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
|
||||
<div v-if="liveUnavailable" class="status-message compact">
|
||||
Live stream reconnecting…
|
||||
</div>
|
||||
|
||||
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading activity...
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityError" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ activityError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityItems.length" class="thinking-list">
|
||||
<article
|
||||
v-for="item in activityItems"
|
||||
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
||||
class="thinking-item"
|
||||
>
|
||||
<div class="thinking-meta">
|
||||
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
|
||||
<span>{{ formatActivityTime(item) }}</span>
|
||||
</div>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No recent activity.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Config section -->
|
||||
<div class="config-section">
|
||||
<div v-if="configsLoading" class="status-message">
|
||||
@@ -297,6 +586,9 @@ onMounted(async () => {
|
||||
:saving="editorState.saving"
|
||||
:save-status="editorState.saveStatus"
|
||||
:save-message="editorState.saveMessage"
|
||||
:backup-status="editorState.backupStatus"
|
||||
:reload-status="editorState.reloadStatus"
|
||||
:reload-message="editorState.reloadMessage"
|
||||
@update-content="onContentChange"
|
||||
@save="saveFile"
|
||||
/>
|
||||
@@ -343,6 +635,9 @@ onMounted(async () => {
|
||||
.status-message.error {
|
||||
color: #e16e75;
|
||||
}
|
||||
.status-message.compact {
|
||||
padding: 20px;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -416,9 +711,144 @@ onMounted(async () => {
|
||||
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
||||
.status-sep { color: #3d4152; font-size: 11px; }
|
||||
|
||||
.thinking-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.section-head .eyebrow {
|
||||
display: block;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent, #7b6ef2);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 2px 0 0;
|
||||
color: #e8eaf0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-note {
|
||||
margin: 6px 0 0;
|
||||
color: #6f788b;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
max-width: 560px;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #6b7385;
|
||||
}
|
||||
.live-dot.on {
|
||||
background: #51d49a;
|
||||
}
|
||||
.icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255,.03);
|
||||
color: #9ba3b5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: .65;
|
||||
cursor: default;
|
||||
}
|
||||
.thinking-list {
|
||||
display: grid;
|
||||
}
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: rgba(255,255,255,.05);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.summary-row > div {
|
||||
background: var(--panel);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.summary-card small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: #6f788b;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.summary-row span {
|
||||
display: block;
|
||||
color: #6f788b;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.summary-row p {
|
||||
margin: 0;
|
||||
color: #cbd0dc;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thinking-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.thinking-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.thinking-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: #6f788b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.type-pill {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(123,110,242,.24);
|
||||
color: #aaa1ff;
|
||||
background: rgba(123,110,242,.08);
|
||||
font-size: 9px;
|
||||
}
|
||||
.thinking-item p {
|
||||
margin: 0;
|
||||
color: #cbd0dc;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.summary-inline-error {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-page {
|
||||
max-width: 100%;
|
||||
}
|
||||
.summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal, Users } from '@lucide/vue'
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal, Users, Wifi, WifiOff } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -12,9 +14,27 @@ interface AgentCard {
|
||||
tags: string[]
|
||||
color: string
|
||||
icon: string
|
||||
model?: string
|
||||
statusLabel?: string
|
||||
statusKind?: 'connected' | 'thinking' | 'blocked' | 'ready' | 'stale' | 'error' | 'unsupported'
|
||||
statusDetail?: string | null
|
||||
isActive?: boolean
|
||||
progress?: number
|
||||
currentTask?: string | null
|
||||
}
|
||||
|
||||
const agents: AgentCard[] = [
|
||||
interface GatewayRuntimeInfo {
|
||||
reachable: boolean
|
||||
version?: string | null
|
||||
requiredVersion?: string | null
|
||||
versionPinned: boolean
|
||||
versionMatches: boolean
|
||||
versionStatus: 'matched' | 'drift' | 'missing' | 'unpinned' | 'unknown' | 'error'
|
||||
message?: string | null
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
const fallbackAgents: AgentCard[] = [
|
||||
{
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
@@ -71,6 +91,97 @@ const agents: AgentCard[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const agents = ref<AgentCard[]>([])
|
||||
const gateway = ref<GatewayRuntimeInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const agentCount = computed(() => agents.value.length)
|
||||
const hasAgents = computed(() => agents.value.length > 0)
|
||||
const gatewayWarning = computed(() => gateway.value?.warning || '')
|
||||
const gatewayLabel = computed(() => {
|
||||
if (!gateway.value) return 'Gateway wird geprüft'
|
||||
if (!gateway.value.reachable) return gateway.value.message || 'Gateway offline'
|
||||
switch (gateway.value.versionStatus) {
|
||||
case 'matched':
|
||||
return `Pinned ${gateway.value.requiredVersion}`
|
||||
case 'drift':
|
||||
return 'Version drift'
|
||||
case 'missing':
|
||||
return 'Version fehlt'
|
||||
case 'unknown':
|
||||
return 'Version unbekannt'
|
||||
case 'unpinned':
|
||||
return gateway.value.version ? `Detected ${gateway.value.version}` : 'Unpinned'
|
||||
default:
|
||||
return gateway.value.message || 'Gateway online'
|
||||
}
|
||||
})
|
||||
const gatewayChipClass = computed(() => {
|
||||
if (!gateway.value) return 'neutral'
|
||||
if (!gateway.value.reachable) return 'error'
|
||||
if (gateway.value.warning) return 'warn'
|
||||
return 'ok'
|
||||
})
|
||||
|
||||
async function loadMissionControl() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [agentsResponse, gatewayResponse] = await Promise.all([
|
||||
apiFetch('/api/dashboard/agents'),
|
||||
apiFetch('/api/dashboard/gateway'),
|
||||
])
|
||||
|
||||
if (agentsResponse.ok) {
|
||||
const data = await agentsResponse.json()
|
||||
agents.value = data.map((item: any) => enrichAgent(item))
|
||||
} else {
|
||||
error.value = await readErrorMessage(agentsResponse, 'Agenten konnten nicht geladen werden')
|
||||
}
|
||||
|
||||
if (gatewayResponse.ok) {
|
||||
gateway.value = await gatewayResponse.json()
|
||||
} else {
|
||||
const gatewayError = await readErrorMessage(gatewayResponse, 'Gateway-Status konnte nicht geladen werden')
|
||||
error.value = error.value ? `${error.value} · ${gatewayError}` : gatewayError
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Mission Control konnte nicht geladen werden'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function enrichAgent(item: any): AgentCard {
|
||||
const fallback = fallbackAgents.find(a => a.id === item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name || fallback?.name || item.id,
|
||||
role: item.role || fallback?.role || 'Agent',
|
||||
description: item.description || fallback?.description || 'OpenClaw agent',
|
||||
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
|
||||
color: fallback?.color ?? '#7e8799',
|
||||
icon: fallback?.icon ?? 'bot',
|
||||
model: item.model,
|
||||
statusLabel: item.statusLabel,
|
||||
statusKind: item.statusKind,
|
||||
statusDetail: item.statusDetail,
|
||||
isActive: item.isActive,
|
||||
progress: item.progress,
|
||||
currentTask: item.currentTask,
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string) {
|
||||
try {
|
||||
const payload = await response.json()
|
||||
return payload?.error || payload?.message || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function goToAgent(id: string) {
|
||||
router.push(`/agents/${id}`)
|
||||
}
|
||||
@@ -86,6 +197,34 @@ function resolveIcon(iconName: string) {
|
||||
default: return Bot
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(agent: AgentCard) {
|
||||
switch (agent.statusKind) {
|
||||
case 'connected': return 'connected'
|
||||
case 'thinking': return 'thinking'
|
||||
case 'blocked': return 'blocked'
|
||||
case 'stale': return 'stale'
|
||||
case 'error': return 'error'
|
||||
case 'unsupported': return 'unsupported'
|
||||
default: return agent.isActive ? 'connected' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
function statusCopy(agent: AgentCard) {
|
||||
if (agent.statusDetail) return agent.statusDetail
|
||||
if (agent.currentTask) return agent.currentTask
|
||||
switch (agent.statusKind) {
|
||||
case 'connected': return 'Session ist erreichbar.'
|
||||
case 'thinking': return 'Agent plant den nächsten Schritt.'
|
||||
case 'blocked': return 'Agent wartet auf Entblockung.'
|
||||
case 'stale': return 'Es gab länger kein neues Signal.'
|
||||
case 'error': return 'Gateway konnte den Session-Status nicht lesen.'
|
||||
case 'unsupported': return 'Session meldet einen nicht unterstützten Zustand.'
|
||||
default: return 'Keine aktive Aufgabe gemeldet.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMissionControl)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,16 +236,28 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1>Agents</h1>
|
||||
<p class="header-subtitle">{{ agents.length }} AI agents — each with a real role and a real personality.</p>
|
||||
<p class="header-subtitle">{{ agentCount }} agents · {{ gatewayLabel }}</p>
|
||||
</div>
|
||||
<div class="gateway-chip" :class="gatewayChipClass">
|
||||
<Wifi v-if="gateway?.reachable" :size="13" />
|
||||
<WifiOff v-else :size="13" />
|
||||
{{ gateway?.version || gatewayLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="load-error">Lade Gateway-Status...</div>
|
||||
<div v-else-if="error" class="load-error">{{ error }}</div>
|
||||
<div v-if="gatewayWarning" class="gateway-warning">
|
||||
{{ gatewayWarning }}
|
||||
</div>
|
||||
|
||||
<!-- Agent grid -->
|
||||
<div class="agents-grid">
|
||||
<div v-if="hasAgents" class="agents-grid">
|
||||
<article
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
class="agent-card"
|
||||
:class="`status-${statusTone(agent)}`"
|
||||
:style="{ '--card-color': agent.color }"
|
||||
@click="goToAgent(agent.id)"
|
||||
>
|
||||
@@ -122,6 +273,15 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-desc">{{ agent.description }}</p>
|
||||
<div class="agent-runtime">
|
||||
<span :class="['runtime-dot', statusTone(agent)]"></span>
|
||||
<span>{{ agent.statusLabel || (agent.isActive ? 'Arbeitet' : 'Bereit') }}</span>
|
||||
<span v-if="agent.model" class="runtime-model">{{ agent.model }}</span>
|
||||
</div>
|
||||
<p class="runtime-detail">{{ statusCopy(agent) }}</p>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${agent.progress ?? 0}%`, background: agent.color }"></span>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
<span
|
||||
v-for="tag in agent.tags"
|
||||
@@ -139,6 +299,10 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<h3>Keine Agenten sichtbar</h3>
|
||||
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -177,6 +341,58 @@ function resolveIcon(iconName: string) {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
}
|
||||
.gateway-chip {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: #9ba3b5;
|
||||
font-size: 10px;
|
||||
}
|
||||
.gateway-chip.ok {
|
||||
color: #51d49a;
|
||||
border-color: rgba(81, 212, 154, .25);
|
||||
}
|
||||
.gateway-chip.warn {
|
||||
color: #e5b05e;
|
||||
border-color: rgba(229, 176, 94, .28);
|
||||
}
|
||||
.gateway-chip.error {
|
||||
color: #f29b9b;
|
||||
border-color: rgba(242, 155, 155, .3);
|
||||
}
|
||||
.load-error {
|
||||
margin-bottom: 14px;
|
||||
color: #e5b05e;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gateway-warning,
|
||||
.empty-state {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(229, 176, 94, .24);
|
||||
background: rgba(229, 176, 94, .08);
|
||||
color: #f1d7aa;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-state {
|
||||
border-color: var(--line);
|
||||
background: rgba(255,255,255,.03);
|
||||
color: #aab2c3;
|
||||
}
|
||||
.empty-state h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Agent grid */
|
||||
.agents-grid {
|
||||
@@ -201,6 +417,15 @@ function resolveIcon(iconName: string) {
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.agent-card.status-error {
|
||||
border-color: rgba(242, 155, 155, .22);
|
||||
}
|
||||
.agent-card.status-unsupported {
|
||||
border-color: rgba(229, 176, 94, .22);
|
||||
}
|
||||
.agent-card.status-stale {
|
||||
border-color: rgba(244, 164, 96, .22);
|
||||
}
|
||||
|
||||
.card-stripe {
|
||||
height: 3px;
|
||||
@@ -256,6 +481,60 @@ function resolveIcon(iconName: string) {
|
||||
margin: 0 0 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.agent-runtime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: #8a92a5;
|
||||
font-size: 9.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.runtime-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #6b7385;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runtime-dot.on {
|
||||
background: #51d49a;
|
||||
}
|
||||
.runtime-dot.connected { background: #51d49a; }
|
||||
.runtime-dot.thinking { background: #79aaff; }
|
||||
.runtime-dot.blocked { background: #f87171; }
|
||||
.runtime-dot.stale { background: #f59e0b; }
|
||||
.runtime-dot.error { background: #f29b9b; }
|
||||
.runtime-dot.unsupported { background: #e5b05e; }
|
||||
.runtime-dot.ready { background: #6b7385; }
|
||||
.runtime-model {
|
||||
margin-left: auto;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: #6f788b;
|
||||
}
|
||||
.runtime-detail {
|
||||
margin: 0 0 10px;
|
||||
min-height: 28px;
|
||||
color: #7e8799;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -264,8 +264,10 @@ function childStatusSummary(taskId: string): string {
|
||||
}
|
||||
|
||||
function activityHint(task: BoardTask): string {
|
||||
const childSummary = childStatusSummary(task.id)
|
||||
if (childSummary) return childSummary
|
||||
|
||||
return task.lastActivityMessage?.trim()
|
||||
|| childStatusSummary(task.id)
|
||||
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user