refactor(ui): fold the live runtime view into the Employees page

This commit is contained in:
matevip 2026-05-16 14:50:58 +08:00
parent c320aab1ba
commit 82594878a0
13 changed files with 410 additions and 487 deletions

View File

@ -19,7 +19,7 @@ import java.util.stream.Collectors;
/**
* Joins the live in-memory views ({@link ChatStreamTracker}, {@link SubagentRegistry})
* with agent metadata so the admin Backstage UI can render one card per
* with agent metadata so the admin Live view can render one card per
* working agent without making the frontend traverse three independent
* services.
*

View File

@ -21,14 +21,14 @@ import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
/**
* Admin-only Backstage surface: the global view of every in-flight agent
* Admin-only live runtime surface: the global view of every in-flight agent
* turn plus the controls to friendly-stop, force-recycle, or sweep stuck
* runs. Distinct from {@code /api/v1/subagents/...} which is per-conversation
* owner-scoped this controller is intentionally cross-tenant for the
* operator role.
*/
@Slf4j
@Tag(name = "Agent Runtime (Backstage)")
@Tag(name = "Agent Runtime (Live)")
@RestController
@RequestMapping("/api/v1/admin/agent-runtime")
@RequiredArgsConstructor

View File

@ -1563,7 +1563,7 @@ public class ChatStreamTracker {
}
}
// ===== Runtime snapshot surface (admin Backstage) =====
// ===== Runtime snapshot surface (admin Live view) =====
/**
* Bind the resolved agent + owner to the active run so the runtime
@ -1603,7 +1603,7 @@ public class ChatStreamTracker {
) {}
/**
* Snapshot every active run. Used by the admin Backstage to render the
* Snapshot every active run. Used by the admin Live view to render the
* global "what are my agents doing right now" view. Returned list is a
* defensive copy callers may freely sort / filter it.
*/
@ -1640,7 +1640,7 @@ public class ChatStreamTracker {
}
/**
* Force a wedged run to terminate. Used by the admin Backstage's
* Force a wedged run to terminate. Used by the admin Live view's
* "End it" action when the friendly stop has been observed not to take
* effect (model wedged in a tool call beyond the timeout). Sequence
* matches what {@link #onShutdown()} does for individual runs.

View File

@ -229,8 +229,8 @@ export const activityApi = {
http.get('/activity/feed', { params }),
}
// ==================== Backstage (admin runtime view) ====================
export interface BackstageRunCard {
// ==================== Live (admin runtime view) ====================
export interface LiveRunCard {
conversationId: string
agentId: number | null
agentName: string | null
@ -252,7 +252,7 @@ export interface BackstageRunCard {
subagentCount: number
}
export interface BackstageSubagentCard {
export interface LiveSubagentCard {
subagentId: string
parentConversationId: string | null
childConversationId: string | null
@ -267,7 +267,7 @@ export interface BackstageSubagentCard {
ageMs: number
}
export interface BackstageSummary {
export interface LiveSummary {
running: number
stuck: number
orphan: number
@ -275,15 +275,15 @@ export interface BackstageSummary {
subagentsActive: number
}
export interface BackstageSnapshot {
summary: BackstageSummary
runs: BackstageRunCard[]
subagents: BackstageSubagentCard[]
export interface LiveSnapshot {
summary: LiveSummary
runs: LiveRunCard[]
subagents: LiveSubagentCard[]
timestamp: number
}
export const backstageApi = {
snapshot: () => http.get<{ data: BackstageSnapshot }>('/admin/agent-runtime/snapshot'),
export const liveApi = {
snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'),
stop: (conversationId: string) =>
http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`),
recycle: (conversationId: string) =>

View File

@ -6,7 +6,7 @@
<button
class="focus-close"
type="button"
:aria-label="t('backstage.actions.close')"
:aria-label="t('live.actions.close')"
@click="$emit('close')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@ -28,7 +28,7 @@
<span v-else class="agent-avatar-letter focus-avatar-letter">{{ avatarLetter(run) }}</span>
</div>
</div>
<div class="focus-title">{{ run.agentName || t('backstage.unknownAgent') }}</div>
<div class="focus-title">{{ run.agentName || t('live.unknownAgent') }}</div>
<div class="focus-subtitle" v-if="run.username">@{{ run.username }}</div>
</div>
@ -62,7 +62,7 @@
</svg>
<div class="ring-label">
<div class="ring-value">{{ formatAge(run.ageMs) }}</div>
<div class="ring-caption">{{ t('backstage.detail.runningFor') }}</div>
<div class="ring-caption">{{ t('live.detail.runningFor') }}</div>
</div>
</div>
@ -73,27 +73,27 @@
<div class="focus-tiles">
<div class="focus-tile">
<div class="focus-tile-value">{{ formatAge(run.msSinceLastEvent) }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.lastHeard') }}</div>
<div class="focus-tile-label">{{ t('live.detail.lastHeard') }}</div>
</div>
<div class="focus-tile">
<div
class="focus-tile-value"
:class="{ 'focus-tile-warn': run.subscriberCount === 0 }"
>{{ run.subscriberCount }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.audience') }}</div>
<div class="focus-tile-label">{{ t('live.detail.audience') }}</div>
</div>
<div class="focus-tile">
<div
class="focus-tile-value focus-tile-id"
:title="run.conversationId"
>#{{ shortId(run.conversationId) }}</div>
<div class="focus-tile-label">{{ t('backstage.detail.session') }}</div>
<div class="focus-tile-label">{{ t('live.detail.session') }}</div>
</div>
</div>
<!-- Helpers (subagents) -->
<div v-if="subagents.length > 0" class="focus-section">
<div class="focus-section-title">{{ t('backstage.detail.helpers') }}</div>
<div class="focus-section-title">{{ t('live.detail.helpers') }}</div>
<div v-for="sub in subagents" :key="sub.subagentId" class="focus-sub-row">
<div class="focus-sub-icon" :style="avatarBgStyle(sub)">
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="20" fallback="🤖" />
@ -106,17 +106,17 @@
</div>
</div>
<button class="focus-sub-stop" type="button" @click="$emit('interrupt-sub', sub)">
{{ t('backstage.actions.stop') }}
{{ t('live.actions.stop') }}
</button>
</div>
</div>
<div class="focus-actions">
<button class="focus-btn focus-btn-soft" type="button" @click="$emit('stop', run)">
{{ t('backstage.actions.stop') }}
{{ t('live.actions.stop') }}
</button>
<button class="focus-btn focus-btn-strong" type="button" @click="$emit('recycle', run)">
{{ t('backstage.actions.endIt') }}
{{ t('live.actions.endIt') }}
</button>
</div>
</div>
@ -129,20 +129,20 @@
import { onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { useBackstageAgent } from '@/composables/useBackstageAgent'
import type { BackstageRunCard, BackstageSubagentCard } from '@/api'
import { useLiveAgent } from '@/composables/useLiveAgent'
import type { LiveRunCard, LiveSubagentCard } from '@/api'
const props = defineProps<{
open: boolean
run: BackstageRunCard | null
subagents: BackstageSubagentCard[]
run: LiveRunCard | null
subagents: LiveSubagentCard[]
}>()
const emit = defineEmits<{
close: []
stop: [run: BackstageRunCard]
recycle: [run: BackstageRunCard]
'interrupt-sub': [sub: BackstageSubagentCard]
stop: [run: LiveRunCard]
recycle: [run: LiveRunCard]
'interrupt-sub': [sub: LiveSubagentCard]
}>()
const { t } = useI18n()
@ -154,7 +154,7 @@ const {
humanSentence,
stuckCallout,
formatAge,
} = useBackstageAgent()
} = useLiveAgent()
// SVG ring geometry: r=54 gives a circumference of ~339.292.
// Map the agent's age over a 5-minute window to a stroke-dashoffset so the

View File

@ -1,164 +1,147 @@
<template>
<div class="mc-page-shell">
<div class="mc-page-frame">
<div class="mc-page-inner backstage-page">
<!-- Header: one sentence, no jargon -->
<div class="mc-page-header">
<div class="header-lead">
<div class="mc-page-kicker">{{ t('backstage.kicker') }}</div>
<h1 class="mc-page-title">{{ t('backstage.title') }}</h1>
<p class="mc-page-desc">{{ headlineMessage }}</p>
<div v-if="statusSegments.length" class="head-meta">
<template v-for="(seg, i) in statusSegments" :key="seg.key">
<span class="head-meta-sep" v-if="i > 0">·</span>
<span class="head-meta-seg" :class="seg.tone">{{ seg.text }}</span>
</template>
</div>
</div>
<div class="header-actions">
<button
class="chip-btn"
:class="{ 'is-paused': !autoRefresh }"
:title="autoRefresh ? t('backstage.actions.pauseRefresh') : t('backstage.actions.resumeRefresh')"
@click="toggleAutoRefresh"
>
<span class="chip-pulse" v-if="autoRefresh"></span>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span>{{ autoRefresh ? t('backstage.actions.live') : t('backstage.actions.paused') }}</span>
</button>
<button
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
class="chip-btn chip-btn-warm"
@click="confirmSweep"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6l-1.2 14a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 6"/>
<path d="M10 11v6M14 11v6"/>
</svg>
{{ t('backstage.actions.tidyUp') }}
</button>
</div>
</div>
<div class="live-panel">
<!-- Toolbar: live toggle (left) + status filters + sweep (right) -->
<div class="live-toolbar">
<button
class="chip-btn"
:class="{ 'is-paused': !autoRefresh }"
:title="autoRefresh ? t('live.actions.pauseRefresh') : t('live.actions.resumeRefresh')"
@click="toggleAutoRefresh"
>
<span class="chip-pulse" v-if="autoRefresh"></span>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
</button>
<!-- Loading -->
<div v-if="isInitialLoading" class="cards-grid">
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
<el-skeleton :rows="2" animated />
</div>
</div>
<div v-if="showFilterRow" class="filter-row">
<button
v-for="opt in filterOptions"
:key="opt.key"
class="filter-chip"
:class="{ 'is-active': activeFilter === opt.key, [`tone-${opt.tone}`]: true }"
@click="activeFilter = opt.key"
>
<span class="filter-chip-label">{{ opt.label }}</span>
<span class="filter-chip-count">{{ opt.count }}</span>
</button>
</div>
<!-- Empty: nothing to see -->
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
<div class="empty-orb"></div>
<div class="empty-line">{{ t('backstage.empty.allQuiet') }}</div>
<div class="empty-hint">{{ t('backstage.empty.hint') }}</div>
</div>
<div class="toolbar-spacer"></div>
<!-- Active runs: filter row (when there's variety) + cards grid -->
<template v-else>
<div v-if="showFilterRow" class="filter-row">
<button
v-for="opt in filterOptions"
:key="opt.key"
class="filter-chip"
:class="{ 'is-active': activeFilter === opt.key, [`tone-${opt.tone}`]: true }"
@click="activeFilter = opt.key"
>
<span class="filter-chip-label">{{ opt.label }}</span>
<span class="filter-chip-count">{{ opt.count }}</span>
</button>
</div>
<button
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
class="chip-btn chip-btn-warm"
@click="confirmSweep"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6l-1.2 14a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 6"/>
<path d="M10 11v6M14 11v6"/>
</svg>
{{ t('live.actions.tidyUp') }}
</button>
</div>
<div class="cards-grid">
<article
v-for="run in visibleRuns"
:key="run.conversationId"
class="agent-card mc-surface-card"
:class="cardClass(run)"
@click="openDetail(run)"
>
<!-- Top: avatar (with status ring) + name -->
<div class="agent-card-top">
<div class="agent-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
<div class="agent-avatar" :style="avatarBgStyle(run)">
<SkillIcon
v-if="run.agentIcon"
:value="run.agentIcon"
:size="34"
fallback="🤖"
/>
<span v-else class="agent-avatar-letter">{{ avatarLetter(run) }}</span>
</div>
</div>
<div class="agent-id">
<div class="agent-name">{{ run.agentName || t('backstage.unknownAgent') }}</div>
<div class="agent-owner" v-if="run.username">@{{ run.username }}</div>
</div>
</div>
<!-- Status sentence + (when present) the tool chip standing
on its own so a long tool name doesn't get ellipsis-eaten. -->
<div class="agent-saying-row">
<span class="agent-saying">{{ humanSentence(run) }}</span>
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
{{ run.runningToolName }}
</span>
</div>
<!-- Meta: just the time + orphan hint (id moved to detail) -->
<div class="agent-meta-row">
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('backstage.orphanHint')">
{{ t('backstage.orphan') }}
</span>
</div>
<div class="agent-bar" v-if="showBar(run)">
<div class="bar-fill" :style="progressFillStyle(run)"></div>
</div>
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
<div class="agent-card-foot">
<div class="subagent-stack" v-if="childrenOf(run).length > 0">
<div
v-for="sub in childrenOf(run).slice(0, 3)"
:key="sub.subagentId"
class="subagent-chip"
:style="avatarBgStyle(sub)"
:title="sub.agentName || sub.subagentId"
>
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="14" fallback="🤖" />
<span v-else class="sub-chip-letter">{{ avatarLetter(sub) }}</span>
</div>
<div v-if="childrenOf(run).length > 3" class="subagent-chip subagent-overflow">
+{{ childrenOf(run).length - 3 }}
</div>
</div>
<div class="card-foot-actions">
<button
class="card-action card-action-soft"
@click.stop="confirmStop(run)"
:title="t('backstage.actions.stopHint')"
>{{ t('backstage.actions.stop') }}</button>
<button
v-if="run.stuckReason"
class="card-action card-action-strong"
@click.stop="confirmRecycle(run)"
:title="t('backstage.actions.endHint')"
>{{ t('backstage.actions.endIt') }}</button>
</div>
</div>
</article>
</div>
</template>
<!-- Loading -->
<div v-if="isInitialLoading" class="cards-grid">
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
<el-skeleton :rows="2" animated />
</div>
</div>
<!-- Empty: nothing to see -->
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
<div class="empty-orb"></div>
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div>
<div class="empty-hint">{{ t('live.empty.hint') }}</div>
</div>
<!-- Active runs -->
<div v-else class="cards-grid">
<article
v-for="run in visibleRuns"
:key="run.conversationId"
class="agent-card mc-surface-card"
:class="cardClass(run)"
@click="openDetail(run)"
>
<!-- Top: avatar (with status ring) + name -->
<div class="agent-card-top">
<div class="agent-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
<div class="agent-avatar" :style="avatarBgStyle(run)">
<SkillIcon
v-if="run.agentIcon"
:value="run.agentIcon"
:size="34"
fallback="🤖"
/>
<span v-else class="agent-avatar-letter">{{ avatarLetter(run) }}</span>
</div>
</div>
<div class="agent-id">
<div class="agent-name">{{ run.agentName || t('live.unknownAgent') }}</div>
<div class="agent-owner" v-if="run.username">@{{ run.username }}</div>
</div>
</div>
<!-- Status sentence + (when present) the tool chip standing
on its own so a long tool name doesn't get ellipsis-eaten. -->
<div class="agent-saying-row">
<span class="agent-saying">{{ humanSentence(run) }}</span>
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
{{ run.runningToolName }}
</span>
</div>
<!-- Meta: just the time + orphan hint (id moved to detail) -->
<div class="agent-meta-row">
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('live.orphanHint')">
{{ t('live.orphan') }}
</span>
</div>
<div class="agent-bar" v-if="showBar(run)">
<div class="bar-fill" :style="progressFillStyle(run)"></div>
</div>
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
<div class="agent-card-foot">
<div class="subagent-stack" v-if="childrenOf(run).length > 0">
<div
v-for="sub in childrenOf(run).slice(0, 3)"
:key="sub.subagentId"
class="subagent-chip"
:style="avatarBgStyle(sub)"
:title="sub.agentName || sub.subagentId"
>
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="14" fallback="🤖" />
<span v-else class="sub-chip-letter">{{ avatarLetter(sub) }}</span>
</div>
<div v-if="childrenOf(run).length > 3" class="subagent-chip subagent-overflow">
+{{ childrenOf(run).length - 3 }}
</div>
</div>
<div class="card-foot-actions">
<button
class="card-action card-action-soft"
@click.stop="confirmStop(run)"
:title="t('live.actions.stopHint')"
>{{ t('live.actions.stop') }}</button>
<button
v-if="run.stuckReason"
class="card-action card-action-strong"
@click.stop="confirmRecycle(run)"
:title="t('live.actions.endHint')"
>{{ t('live.actions.endIt') }}</button>
</div>
</div>
</article>
</div>
</div>
<BackstageFocusPanel
<LiveFocusPanel
:open="drawerOpen"
:run="detail"
:subagents="detail ? childrenOf(detail) : []"
@ -174,10 +157,10 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import SkillIcon from '@/components/common/SkillIcon.vue'
import BackstageFocusPanel from '@/components/backstage/BackstageFocusPanel.vue'
import { useBackstageAgent } from '@/composables/useBackstageAgent'
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
import { useLiveAgent } from '@/composables/useLiveAgent'
import { mcConfirm } from '@/components/common/useConfirm'
import { backstageApi, type BackstageSnapshot, type BackstageRunCard, type BackstageSubagentCard } from '@/api'
import { liveApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard } from '@/api'
const { t } = useI18n()
const {
@ -187,50 +170,25 @@ const {
dotTitle,
humanSentence,
formatAge,
} = useBackstageAgent()
} = useLiveAgent()
type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
const snapshot = ref<BackstageSnapshot | null>(null)
const snapshot = ref<LiveSnapshot | null>(null)
const isInitialLoading = ref(true)
const autoRefresh = ref(true)
const drawerOpen = ref(false)
const detail = ref<BackstageRunCard | null>(null)
const detail = ref<LiveRunCard | null>(null)
const activeFilter = ref<FilterKey>('all')
let timer: ReturnType<typeof setInterval> | null = null
const headlineMessage = computed(() => {
if (!snapshot.value) return t('backstage.headline.loading')
const s = snapshot.value.summary
if (s.running === 0) return t('backstage.headline.allQuiet')
if (s.stuck > 0) return t('backstage.headline.someoneNeedsAttention', { n: s.stuck })
if (s.orphan > 0) return t('backstage.headline.workingAlone', { running: s.running, orphan: s.orphan })
return t('backstage.headline.working', { n: s.running })
})
/**
* Runbook-style monospace status line. Each segment is a small fact, joined
* with a `·` separator. Tone hints colour subtly (warn / accent / muted).
* Hidden when nothing is running empty state already says it best.
*/
const statusSegments = computed<{ key: string; text: string; tone: 'muted' | 'accent' | 'warn' }[]>(() => {
const s = snapshot.value?.summary
if (!s || s.running === 0) return []
const segs: { key: string; text: string; tone: 'muted' | 'accent' | 'warn' }[] = []
segs.push({ key: 'running', text: t('backstage.status.running', { n: s.running }), tone: 'accent' })
if (s.stuck > 0) segs.push({ key: 'stuck', text: t('backstage.status.stuck', { n: s.stuck }), tone: 'warn' })
if (s.orphan > 0) segs.push({ key: 'orphan', text: t('backstage.status.orphan', { n: s.orphan }), tone: 'muted' })
if (s.subagentsActive > 0) segs.push({ key: 'helpers', text: t('backstage.status.helpers', { n: s.subagentsActive }), tone: 'muted' })
return segs
})
function isWorking(r: BackstageRunCard): boolean {
function isWorking(r: LiveRunCard): boolean {
return !r.stuckReason && !r.orphan
}
function isAttention(r: BackstageRunCard): boolean {
function isAttention(r: LiveRunCard): boolean {
return !!r.stuckReason
}
function isQuiet(r: BackstageRunCard): boolean {
function isQuiet(r: LiveRunCard): boolean {
return r.orphan && !r.stuckReason
}
@ -245,11 +203,11 @@ const filterOptions = computed(() => {
// Always show All; only show secondary chips that have at least one match
// an empty chip is dead pixels.
const opts: { key: FilterKey; label: string; count: number; tone: string }[] = [
{ key: 'all', label: t('backstage.filters.all'), count: counts.all, tone: 'neutral' },
{ key: 'all', label: t('live.filters.all'), count: counts.all, tone: 'neutral' },
]
if (counts.working > 0) opts.push({ key: 'working', label: t('backstage.filters.working'), count: counts.working, tone: 'good' })
if (counts.attention > 0) opts.push({ key: 'attention', label: t('backstage.filters.attention'), count: counts.attention, tone: 'warn' })
if (counts.quiet > 0) opts.push({ key: 'quiet', label: t('backstage.filters.quiet'), count: counts.quiet, tone: 'muted' })
if (counts.working > 0) opts.push({ key: 'working', label: t('live.filters.working'), count: counts.working, tone: 'good' })
if (counts.attention > 0) opts.push({ key: 'attention', label: t('live.filters.attention'), count: counts.attention, tone: 'warn' })
if (counts.quiet > 0) opts.push({ key: 'quiet', label: t('live.filters.quiet'), count: counts.quiet, tone: 'muted' })
return opts
})
@ -272,13 +230,13 @@ const showFilterRow = computed(() => {
* Without this, runs are listed in whatever order the snapshot returned
* them, and the one card you actually need to look at can be 4 rows down.
*/
function tierOf(r: BackstageRunCard): number {
function tierOf(r: LiveRunCard): number {
if (r.stuckReason) return 0
if (r.orphan) return 1
return 2
}
const visibleRuns = computed<BackstageRunCard[]>(() => {
const visibleRuns = computed<LiveRunCard[]>(() => {
const runs = snapshot.value?.runs ?? []
const filtered = (() => {
switch (activeFilter.value) {
@ -297,7 +255,7 @@ const visibleRuns = computed<BackstageRunCard[]>(() => {
})
// If the filter the user picked no longer matches any rows (e.g., the stuck
// run resolved itself between refreshes), drop back to All so the page
// run resolved itself between refreshes), drop back to All so the panel
// doesn't go inexplicably empty.
watch(filterOptions, opts => {
if (!opts.some(o => o.key === activeFilter.value)) {
@ -305,7 +263,7 @@ watch(filterOptions, opts => {
}
})
function cardClass(run: BackstageRunCard) {
function cardClass(run: LiveRunCard) {
return {
'is-stuck': !!run.stuckReason,
'is-orphan': run.orphan && !run.stuckReason,
@ -318,21 +276,21 @@ function cardClass(run: BackstageRunCard) {
* starts to matter. Under 30s is "barely started" rendering 1px of bar
* adds visual noise without informing the user.
*/
function showBar(run: BackstageRunCard): boolean {
function showBar(run: LiveRunCard): boolean {
return run.ageMs > 30_000
}
function progressFillStyle(run: BackstageRunCard) {
function progressFillStyle(run: LiveRunCard) {
// Map age to 0..100% over the 5-minute window. Beyond that we just stay full.
const pct = Math.min(100, ((run.ageMs - 30_000) / 270_000) * 100)
return { width: `${Math.max(4, pct)}%` }
}
function childrenOf(run: BackstageRunCard): BackstageSubagentCard[] {
function childrenOf(run: LiveRunCard): LiveSubagentCard[] {
return snapshot.value?.subagents.filter(s => s.parentConversationId === run.conversationId) ?? []
}
function openDetail(run: BackstageRunCard) {
function openDetail(run: LiveRunCard) {
// Clicking the same card while the panel is open closes it toggle.
// Otherwise swap content and (re)open. The focus panel handles its own
// enter/leave animations, so all we manage here is the open boolean and
@ -351,14 +309,14 @@ function closeDetail() {
async function refresh() {
try {
const res: any = await backstageApi.snapshot()
snapshot.value = (res?.data ?? res) as BackstageSnapshot
const res: any = await liveApi.snapshot()
snapshot.value = (res?.data ?? res) as LiveSnapshot
if (detail.value && snapshot.value) {
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
if (fresh) detail.value = fresh
}
} catch (e: any) {
if (isInitialLoading.value) mcToast.error(e?.message || t('backstage.errors.loadFailed'))
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
} finally {
isInitialLoading.value = false
}
@ -375,78 +333,78 @@ function toggleAutoRefresh() {
}
}
async function confirmStop(run: BackstageRunCard) {
async function confirmStop(run: LiveRunCard) {
const ok = await mcConfirm({
title: t('backstage.confirm.stopTitle'),
message: t('backstage.confirm.stopBody', { name: run.agentName || t('backstage.unknownAgent') }),
confirmText: t('backstage.actions.stop'),
title: t('live.confirm.stopTitle'),
message: t('live.confirm.stopBody', { name: run.agentName || t('live.unknownAgent') }),
confirmText: t('live.actions.stop'),
cancelText: t('common.cancel'),
tone: 'primary',
})
if (!ok) return
try {
await backstageApi.stop(run.conversationId)
mcToast.success(t('backstage.toast.stopped'))
await liveApi.stop(run.conversationId)
mcToast.success(t('live.toast.stopped'))
refresh()
} catch (e: any) {
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
mcToast.error(e?.message || t('live.errors.loadFailed'))
}
}
async function confirmRecycle(run: BackstageRunCard) {
async function confirmRecycle(run: LiveRunCard) {
const ok = await mcConfirm({
title: t('backstage.confirm.endTitle', { name: run.agentName || t('backstage.unknownAgent') }),
message: t('backstage.confirm.endBody', { name: run.agentName || t('backstage.unknownAgent') }),
confirmText: t('backstage.actions.endIt'),
title: t('live.confirm.endTitle', { name: run.agentName || t('live.unknownAgent') }),
message: t('live.confirm.endBody', { name: run.agentName || t('live.unknownAgent') }),
confirmText: t('live.actions.endIt'),
cancelText: t('common.cancel'),
tone: 'danger',
})
if (!ok) return
try {
await backstageApi.recycle(run.conversationId)
mcToast.success(t('backstage.toast.ended'))
await liveApi.recycle(run.conversationId)
mcToast.success(t('live.toast.ended'))
drawerOpen.value = false
refresh()
} catch (e: any) {
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
mcToast.error(e?.message || t('live.errors.loadFailed'))
}
}
async function confirmInterruptSub(sub: BackstageSubagentCard) {
async function confirmInterruptSub(sub: LiveSubagentCard) {
const ok = await mcConfirm({
title: t('backstage.confirm.subTitle'),
message: t('backstage.confirm.subBody', { name: sub.agentName || sub.subagentId }),
confirmText: t('backstage.actions.stop'),
title: t('live.confirm.subTitle'),
message: t('live.confirm.subBody', { name: sub.agentName || sub.subagentId }),
confirmText: t('live.actions.stop'),
cancelText: t('common.cancel'),
tone: 'primary',
})
if (!ok) return
try {
await backstageApi.interruptSubagent(sub.subagentId)
mcToast.success(t('backstage.toast.subStopped'))
await liveApi.interruptSubagent(sub.subagentId)
mcToast.success(t('live.toast.subStopped'))
refresh()
} catch (e: any) {
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
mcToast.error(e?.message || t('live.errors.loadFailed'))
}
}
async function confirmSweep() {
const stuckCount = snapshot.value?.summary.stuck ?? 0
const ok = await mcConfirm({
title: t('backstage.confirm.sweepTitle'),
message: t('backstage.confirm.sweepBody', { n: stuckCount }),
confirmText: t('backstage.actions.tidyUp'),
title: t('live.confirm.sweepTitle'),
message: t('live.confirm.sweepBody', { n: stuckCount }),
confirmText: t('live.actions.tidyUp'),
cancelText: t('common.cancel'),
tone: 'danger',
})
if (!ok) return
try {
const res: any = await backstageApi.sweep()
const res: any = await liveApi.sweep()
const recycled = res?.data?.recycled ?? 0
mcToast.success(t('backstage.toast.swept', { n: recycled }))
mcToast.success(t('live.toast.swept', { n: recycled }))
refresh()
} catch (e: any) {
mcToast.error(e?.message || t('backstage.errors.loadFailed'))
mcToast.error(e?.message || t('live.errors.loadFailed'))
}
}
@ -461,57 +419,22 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.backstage-page {
.live-panel {
--card-radius: 24px;
}
.header-lead {
min-width: 0;
}
/* ===== Runbook-style monospace status line ===== */
.head-meta {
/* ===== Toolbar: live toggle + status filters + sweep ===== */
.live-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 14px;
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 11.5px;
letter-spacing: 0.02em;
color: var(--mc-text-tertiary);
font-variant-numeric: tabular-nums;
gap: 10px;
margin-bottom: 18px;
}
.head-meta-sep {
color: var(--mc-border-strong);
user-select: none;
}
html.dark .head-meta-sep {
/* Border-strong reads too pale in dark mode for a punctuation glyph. */
color: var(--mc-text-tertiary);
opacity: 0.45;
}
.head-meta-seg.muted {
color: var(--mc-text-tertiary);
}
.head-meta-seg.accent {
color: hsl(155, 50%, 38%);
}
html.dark .head-meta-seg.accent {
color: hsl(155, 55%, 62%);
}
.head-meta-seg.warn {
color: hsl(20, 75%, 42%);
}
html.dark .head-meta-seg.warn {
color: hsl(28, 80%, 70%);
.toolbar-spacer {
flex: 1;
min-width: 0;
}
/* ===== Filter chip row (kanban-inspired, soft) ===== */
@ -519,7 +442,6 @@ html.dark .head-meta-seg.warn {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 18px;
}
.filter-chip {
@ -611,12 +533,6 @@ html.dark .filter-chip.is-active.tone-good {
}
/* ===== Header chips ===== */
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.chip-btn {
display: inline-flex;
align-items: center;
@ -1028,10 +944,9 @@ html.dark .agent-card.is-stuck .tool-chip {
}
/*
* Subagent stack overlapping circular avatars, like Linear's assignee
* column. Up to 3 visible; the rest collapse into a "+N" chip.
* Replaces the old "{n} 个帮手" pill: subagents are good news, they
* deserve faces, not a count.
* Subagent stack overlapping circular avatars, like an assignee column.
* Up to 3 visible; the rest collapse into a "+N" chip. Subagents are good
* news, they deserve faces, not a count.
*/
.subagent-stack {
display: inline-flex;
@ -1155,5 +1070,4 @@ html.dark .card-action-strong {
font-size: 13px;
color: var(--mc-text-tertiary);
}
</style>

View File

@ -1,18 +1,18 @@
/**
* Shared display helpers for the Backstage page and its child components.
* Shared display helpers for the Live runtime view and its child components.
* Pure formatting and classification no API calls, no shared state.
*
* The parent (Backstage.vue) and the focus panel both render agent cards
* with avatars, status rings, human sentences, and elapsed-time strings;
* keeping these in one place avoids drift between the two surfaces when
* a status string or ring rule changes.
* The live panel and the focus panel both render agent cards with avatars,
* status rings, human sentences, and elapsed-time strings; keeping these in
* one place avoids drift between the two surfaces when a status string or
* ring rule changes.
*/
import { useI18n } from 'vue-i18n'
import type { BackstageRunCard, BackstageSubagentCard } from '@/api'
import type { LiveRunCard, LiveSubagentCard } from '@/api'
type AnyRun = BackstageRunCard | BackstageSubagentCard
type AnyRun = LiveRunCard | LiveSubagentCard
export function useBackstageAgent() {
export function useLiveAgent() {
const { t } = useI18n()
function avatarLetter(run: AnyRun): string {
@ -47,7 +47,7 @@ export function useBackstageAgent() {
* to healthy which is what you'd want for a helper that's still around.
*/
function ringClass(run: AnyRun): Record<string, boolean> {
const r = run as BackstageRunCard
const r = run as LiveRunCard
if (r.stuckReason) return { 'ring-stuck': true }
if (r.orphan) return { 'ring-orphan': true }
if (r.firstTokenReceived === false && r.ageMs < 30_000) return { 'ring-thinking': true }
@ -55,10 +55,10 @@ export function useBackstageAgent() {
}
function dotTitle(run: AnyRun): string {
const r = run as BackstageRunCard
if (r.stuckReason) return t('backstage.dotTitle.stuck')
if (r.orphan) return t('backstage.dotTitle.orphan')
return t('backstage.dotTitle.healthy')
const r = run as LiveRunCard
if (r.stuckReason) return t('live.dotTitle.stuck')
if (r.orphan) return t('live.dotTitle.orphan')
return t('live.dotTitle.healthy')
}
/**
@ -69,33 +69,33 @@ export function useBackstageAgent() {
* stuckCallout (used in the focus panel) is the one place that still
* mentions the tool name, because there it lives in a long-form alert.
*/
function humanSentence(run: BackstageRunCard): string {
if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilentBare')
if (run.stuckReason === 'idle_silent') return t('backstage.saying.idleSilent')
if (run.stuckReason === 'hard_cap') return t('backstage.saying.hardCap')
if (run.currentPhase === 'awaiting_approval') return t('backstage.saying.awaitingApproval')
if (run.runningToolName) return t('backstage.saying.usingToolBare')
if (run.currentPhase === 'executing_tool') return t('backstage.saying.usingSomething')
if (run.currentPhase === 'summarizing') return t('backstage.saying.wrappingUp')
if (run.currentPhase === 'planning') return t('backstage.saying.planning')
if (!run.firstTokenReceived) return t('backstage.saying.thinking')
return t('backstage.saying.replying')
function humanSentence(run: LiveRunCard): string {
if (run.stuckReason === 'tool_silent') return t('live.saying.toolSilentBare')
if (run.stuckReason === 'idle_silent') return t('live.saying.idleSilent')
if (run.stuckReason === 'hard_cap') return t('live.saying.hardCap')
if (run.currentPhase === 'awaiting_approval') return t('live.saying.awaitingApproval')
if (run.runningToolName) return t('live.saying.usingToolBare')
if (run.currentPhase === 'executing_tool') return t('live.saying.usingSomething')
if (run.currentPhase === 'summarizing') return t('live.saying.wrappingUp')
if (run.currentPhase === 'planning') return t('live.saying.planning')
if (!run.firstTokenReceived) return t('live.saying.thinking')
return t('live.saying.replying')
}
function formatAge(ms: number): string {
if (ms < 1500) return t('backstage.time.justNow')
if (ms < 1500) return t('live.time.justNow')
const sec = Math.floor(ms / 1000)
if (sec < 60) return t('backstage.time.seconds', { n: sec })
if (sec < 60) return t('live.time.seconds', { n: sec })
const min = Math.floor(sec / 60)
if (min < 60) return t('backstage.time.minutes', { n: min, s: sec % 60 })
if (min < 60) return t('live.time.minutes', { n: min, s: sec % 60 })
const hr = Math.floor(min / 60)
return t('backstage.time.hours', { n: hr, m: min % 60 })
return t('live.time.hours', { n: hr, m: min % 60 })
}
function stuckCallout(run: BackstageRunCard): string {
if (run.stuckReason === 'tool_silent') return t('backstage.callout.toolSilent', { tool: run.runningToolName || t('backstage.aTool'), time: formatAge(run.msSinceLastEvent) })
if (run.stuckReason === 'idle_silent') return t('backstage.callout.idleSilent', { time: formatAge(run.msSinceLastEvent) })
return t('backstage.callout.hardCap', { time: formatAge(run.ageMs) })
function stuckCallout(run: LiveRunCard): string {
if (run.stuckReason === 'tool_silent') return t('live.callout.toolSilent', { tool: run.runningToolName || t('live.aTool'), time: formatAge(run.msSinceLastEvent) })
if (run.stuckReason === 'idle_silent') return t('live.callout.idleSilent', { time: formatAge(run.msSinceLastEvent) })
return t('live.callout.hardCap', { time: formatAge(run.ageMs) })
}
return {

View File

@ -381,9 +381,7 @@ export default {
activity: 'Activity',
acpEndpoints: 'ACP Endpoints',
settingsGroup: 'Settings',
agents: 'Digital Employees',
backstage: 'Backstage',
backstageTooltip: 'See what your employees are doing right now',
agents: 'Employees',
security: 'Security',
tokenUsage: 'Token Usage',
cronJobs: 'Cron Jobs',
@ -400,15 +398,15 @@ export default {
appearance: 'Appearance & Language',
roleUser: 'User',
roleAdmin: 'Admin',
shortcutAgents: 'Agents',
shortcutAgents: 'Employees',
shortcutNew: 'New',
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
},
notifications: {
pendingApprovals: '{n} tool call(s) pending approval',
},
backstage: {
kicker: 'Backstage',
live: {
kicker: 'Live',
title: 'See what your employees are doing',
attention: 'Someone needs your attention',
unknownAgent: 'Employee',
@ -1026,12 +1024,12 @@ export default {
},
agents: {
kicker: 'Employee Studio',
title: 'Digital Employees',
desc: 'Hire, train, and manage your digital employees',
title: 'Employees',
desc: 'Hire, train, and manage your employees',
newAgent: 'Hire Employee',
live: {
atWork: '{n} at work — see backstage',
needsAttention: '{n} need attention — see backstage',
views: {
roster: 'Roster',
live: 'Live',
},
templates: {
title: 'Choose a Role',

View File

@ -381,9 +381,7 @@ export default {
activity: '活动记录',
acpEndpoints: 'ACP 端点',
settingsGroup: '设置',
agents: '数字员工',
backstage: '后台',
backstageTooltip: '看看你的数字员工此刻在做什么',
agents: '员工',
security: '安全',
tokenUsage: 'Token 统计',
cronJobs: '定时任务',
@ -400,7 +398,7 @@ export default {
appearance: '外观与语言',
roleUser: '用户',
roleAdmin: '管理员',
shortcutAgents: '智能体',
shortcutAgents: '员工',
shortcutNew: '新建',
shortcutsHint: 'Ctrl+K {agents} | Ctrl+N {newAction}',
},
@ -918,12 +916,12 @@ export default {
},
agents: {
kicker: '员工工作室',
title: '数字员工',
desc: '招募、培训和管理你的数字员工',
title: '员工',
desc: '招募、培训和管理你的员工',
newAgent: '新员工',
live: {
atWork: '{n} 个在干活 · 看现场',
needsAttention: '{n} 个需要看看 · 去现场',
views: {
roster: '花名册',
live: '现场',
},
templates: {
title: '选择岗位',
@ -1720,8 +1718,8 @@ export default {
notifications: {
pendingApprovals: '{n} 个工具调用等待审批',
},
backstage: {
kicker: '后台',
live: {
kicker: '现场',
title: '看看你的数字员工在做什么',
attention: '有几个需要你看看',
unknownAgent: '员工',

View File

@ -40,10 +40,10 @@ const router = createRouter({
meta: { title: 'Agents', requiredCapability: 'manage:agents' },
},
{
// Live runtime view folded into the Agents page as a sub-view.
// Kept as a redirect so old links / bookmarks still resolve.
path: 'backstage',
name: 'Backstage',
component: () => import('@/views/Backstage.vue'),
meta: { title: 'Backstage', requireAdmin: true },
redirect: { path: '/agents', query: { view: 'live' } },
},
{
path: 'wiki',

View File

@ -9,23 +9,28 @@
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
</div>
<div class="header-right">
<router-link
v-if="isAdminRole && backstageRunning > 0"
to="/backstage"
class="live-pill"
:class="{ 'live-pill--alert': backstageStuck > 0 }"
:title="t('backstage.attention')"
>
<span class="live-pill-dot"></span>
<span class="live-pill-text">
{{ backstageStuck > 0
? t('agents.live.needsAttention', { n: backstageStuck })
: t('agents.live.atWork', { n: backstageRunning }) }}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</router-link>
<!-- Roster / Live view switch one team, two states. Admin only.
Sits on the header line, level with the New Employee button. -->
<div v-if="isAdminRole" class="view-switch">
<button
class="view-seg"
:class="{ 'is-active': view === 'roster' }"
@click="setView('roster')"
>{{ t('agents.views.roster') }}</button>
<button
class="view-seg"
:class="{ 'is-active': view === 'live' }"
@click="setView('live')"
>
<span v-if="liveRunning > 0" class="seg-pulse"></span>
{{ t('agents.views.live') }}
<span
v-if="liveRunning > 0"
class="seg-count"
:class="{ warn: liveStuck > 0 }"
>{{ liveRunning }}</span>
</button>
</div>
<button class="btn-primary" @click="openCreateModal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
@ -35,6 +40,8 @@
</div>
</div>
<!-- Roster: the team -->
<template v-if="view === 'roster'">
<div class="agents-toolbar mc-surface-card">
<div class="filter-bar">
<div class="search-box">
@ -123,6 +130,10 @@
<p>{{ t('agents.emptyDesc') }}</p>
<button class="btn-primary" @click="openCreateModal">{{ t('agents.newAgent') }}</button>
</div>
</template>
<!-- Live: what the team is doing right now -->
<LivePanel v-else />
</div>
</div>
@ -486,14 +497,15 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, backstageApi } from '@/api/index'
import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, liveApi } from '@/api/index'
import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import LivePanel from '@/components/live/LivePanel.vue'
import {
emptyProfile,
parsePrompt,
@ -508,6 +520,7 @@ import { filterAgentBindingItems, filterAgentToolGroups } from '@/utils/agentBin
import { useSkillName } from '@/composables/useSkillName'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const { resolveSkillName } = useSkillName()
const agents = ref<Agent[]>([])
@ -715,19 +728,28 @@ const filteredAgents = computed(() => {
return list
})
// Live signal for the "see what's running" header pill admin only.
// Roster Live view switch admin only. The running/stuck counts feed the
// segmented control's pulse + badge so you know whether Live is worth a look.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const backstageRunning = ref(0)
const backstageStuck = ref(0)
let backstagePollTimer: ReturnType<typeof setInterval> | null = null
const view = ref<'roster' | 'live'>(
route.query.view === 'live' && isAdminRole.value ? 'live' : 'roster',
)
const liveRunning = ref(0)
const liveStuck = ref(0)
let livePollTimer: ReturnType<typeof setInterval> | null = null
async function refreshBackstagePill() {
function setView(next: 'roster' | 'live') {
view.value = next
router.replace({ query: next === 'live' ? { view: 'live' } : {} })
}
async function refreshLiveCounts() {
if (!isAdminRole.value) return
try {
const res: any = await backstageApi.snapshot()
const res: any = await liveApi.snapshot()
const data = res?.data ?? res
backstageRunning.value = data?.summary?.running ?? 0
backstageStuck.value = data?.summary?.stuck ?? 0
liveRunning.value = data?.summary?.running ?? 0
liveStuck.value = data?.summary?.stuck ?? 0
} catch {
// Silent stale value is preferable to a flapping number.
}
@ -739,13 +761,13 @@ onMounted(() => {
// Failure is non-fatal the dropdown just shows only "global default".
loadAvailableModels()
if (isAdminRole.value) {
refreshBackstagePill()
backstagePollTimer = setInterval(refreshBackstagePill, 10_000)
refreshLiveCounts()
livePollTimer = setInterval(refreshLiveCounts, 10_000)
}
})
onBeforeUnmount(() => {
if (backstagePollTimer) clearInterval(backstagePollTimer)
if (livePollTimer) clearInterval(livePollTimer)
})
async function loadAgents() {
@ -977,92 +999,87 @@ async function toggleAgent(agent: Agent) {
<style scoped>
.agents-page { gap: 18px; }
/* ===== Backstage live pill in page header ===== */
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.live-pill {
/* ===== Roster / Live segmented switch ===== */
/* Lives in the header row, level with the New Employee button. */
.view-switch {
display: inline-flex;
background: var(--mc-bg-sunken);
border: 1px solid var(--mc-border-light);
border-radius: 999px;
padding: 4px;
gap: 2px;
}
.view-seg {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px 8px 12px;
padding: 6px 18px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid var(--mc-border-light);
border: none;
background: transparent;
color: var(--mc-text-secondary);
font-size: 12.5px;
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
font-family: inherit;
cursor: pointer;
transition: all 0.18s ease;
backdrop-filter: blur(8px);
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
html.dark .live-pill {
background: rgba(255, 255, 255, 0.04);
}
.live-pill:hover {
border-color: var(--mc-border);
.view-seg:hover {
color: var(--mc-text-primary);
transform: translateY(-1px);
}
.live-pill-dot {
.view-seg.is-active {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Pulsing dot — the Live segment is alive when runs are in flight. */
.seg-pulse {
width: 7px;
height: 7px;
border-radius: 50%;
background: hsl(155, 55%, 50%);
position: relative;
animation: live-pill-pulse 2.4s ease-in-out infinite;
background: hsl(140, 55%, 48%);
animation: seg-pulse 2.4s ease-in-out infinite;
}
.live-pill-dot::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsla(155, 55%, 50%, 0.3);
animation: live-pill-halo 2.4s ease-in-out infinite;
@keyframes seg-pulse {
0%, 100% { box-shadow: 0 0 0 0 hsla(140, 55%, 50%, 0.5); }
50% { box-shadow: 0 0 0 5px hsla(140, 55%, 50%, 0); }
}
@keyframes live-pill-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.85); }
.seg-count {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
padding: 1px 7px;
border-radius: 999px;
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
}
@keyframes live-pill-halo {
0%, 100% { opacity: 0; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.6); }
.view-seg.is-active .seg-count {
background: var(--mc-accent-soft);
color: var(--mc-accent);
}
.live-pill--alert {
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.12), hsla(20, 90%, 55%, 0.16));
border-color: hsla(20, 80%, 55%, 0.32);
color: hsl(20, 70%, 40%);
/* Stuck runs turn the badge warm — you should look without switching. */
.seg-count.warn {
background: hsla(20, 90%, 55%, 0.18);
color: hsl(20, 75%, 42%);
}
.live-pill--alert:hover {
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.2), hsla(20, 90%, 55%, 0.25));
color: hsl(20, 75%, 35%);
}
.live-pill--alert .live-pill-dot {
background: hsl(20, 80%, 55%);
animation-duration: 4s;
}
.live-pill--alert .live-pill-dot::before {
background: hsla(20, 80%, 55%, 0.32);
animation-duration: 4s;
}
.live-pill-text {
letter-spacing: -0.005em;
white-space: nowrap;
html.dark .seg-count.warn {
color: hsl(28, 80%, 70%);
}
.btn-primary { display: flex; align-items: center; gap: 6px; padding: 10px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: white; border: none; border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.15s; box-shadow: var(--mc-shadow-soft); }

View File

@ -25,7 +25,7 @@
</button>
<div class="agent-selector">
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="$t('chat.selectAgent')">
<span class="agent-select-trigger__icon" :style="{ color: agentIconColor(currentAgent?.icon) }"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
@ -994,23 +994,21 @@ const eligibleModels = computed(() => {
// Global shortcuts (Ctrl+K agents, Ctrl+N new chat) live in MainLayout so they
// work from any page; this view reacts to the dispatched event when mounted.
function handleChatShortcut(e: Event) {
const action = (e as CustomEvent).detail as 'newChat' | 'selectAgent' | undefined
const action = (e as CustomEvent).detail as 'newChat' | undefined
if (action === 'newChat') {
newConversation()
nextTick(() => chatInputRef.value?.focus?.())
} else if (action === 'selectAgent') {
agentDropdownOpen.value = !agentDropdownOpen.value
}
}
// Cross-page hand-off from MainLayout's global shortcuts: read once on mount
// (before loadAgents triggers syncRouteState, which would wipe the action key)
// and apply after agents are loaded so the dropdown actually has something to show.
let pendingRouteAction: 'newChat' | 'selectAgent' | '' = ''
let pendingRouteAction: 'newChat' | '' = ''
function captureRouteAction() {
const action = route.query.action
if (action === 'newChat' || action === 'selectAgent') {
if (action === 'newChat') {
pendingRouteAction = action
}
}
@ -1021,8 +1019,6 @@ function applyPendingRouteAction() {
if (action === 'newChat') {
newConversation()
nextTick(() => chatInputRef.value?.focus?.())
} else if (action === 'selectAgent') {
agentDropdownOpen.value = true
}
}

View File

@ -53,11 +53,11 @@
<span class="nav-icon" v-html="item.icon"></span>
<span v-if="!effectiveCollapsed" class="nav-label">{{ item.label }}</span>
<NavBadge
v-if="item.path === '/backstage'"
:dot="backstageAlertActive"
v-if="item.path === '/agents' && isAdminRole"
:dot="liveAlertActive"
tone="warning"
:collapsed="effectiveCollapsed"
:title="t('backstage.attention')"
:title="t('live.attention')"
/>
<NavBadge
v-else-if="item.path === '/security' && isAdminRole"
@ -229,12 +229,12 @@ async function fetchHealthStatus() {
}
}
// Sidebar attention signals admin-only. Both `/backstage` (stuck agents)
// and `/security` (pending approvals) read from a shared 15s poller so
// multiple consumers don't multiply HTTP traffic.
// Sidebar attention signals admin-only. Both `/agents` (stuck agents in the
// Live view) and `/security` (pending approvals) read from a shared 15s poller
// so multiple consumers don't multiply HTTP traffic.
const isAdminRole = computed(() => (localStorage.getItem('role') || 'user') === 'admin')
const { stuckAgents, pendingApprovals } = useNotificationCenter()
const backstageAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
//
const isMobile = ref(false)
@ -258,17 +258,20 @@ function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
}
}
type ChatShortcutAction = 'newChat' | 'selectAgent'
const shortcutsHintText = computed(() =>
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
)
function fireChatShortcut(action: ChatShortcutAction) {
function openAgentsMenu() {
if (!workspaceStore.can('manage:agents' as never)) return
if (route.path !== '/agents') router.push('/agents')
}
function fireNewChatShortcut() {
if (route.path === '/chat') {
window.dispatchEvent(new CustomEvent('mc:chat-shortcut', { detail: action }))
window.dispatchEvent(new CustomEvent('mc:chat-shortcut', { detail: 'newChat' }))
} else {
router.push({ path: '/chat', query: { action } })
router.push({ path: '/chat', query: { action: 'newChat' } })
}
}
@ -290,7 +293,11 @@ function onGlobalKeydown(e: KeyboardEvent) {
// still want to let the chat input handle native paste / undo unblocked.
if (key === 'n' && isEditableTarget(e.target)) return
e.preventDefault()
fireChatShortcut(key === 'k' ? 'selectAgent' : 'newChat')
if (key === 'k') {
openAgentsMenu()
} else {
fireNewChatShortcut()
}
}
onMounted(async () => {
@ -318,7 +325,7 @@ onMounted(async () => {
// Fetch initial health status for sidebar indicator
fetchHealthStatus()
// Sidebar attention counts (backstage / security) are driven by
// Sidebar attention counts (live / security) are driven by
// useNotificationCenter it polls when admins are mounted.
})
@ -420,13 +427,6 @@ const navGroups = computed(() => [
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
requiredCapability: 'manage:agents',
},
{
path: '/backstage',
label: t('nav.backstage'),
tooltip: t('nav.backstageTooltip'),
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`,
globalAdmin: true,
},
{
path: '/wiki',
label: t('nav.wiki'),