feat(backstage): avatar status ring, hero focus panel, brand-tone time dial

This commit is contained in:
matevip 2026-05-04 14:00:25 +08:00
parent 4bf4abd984
commit 7708e26851
5 changed files with 1121 additions and 490 deletions

View File

@ -0,0 +1,766 @@
<template>
<Teleport to="body">
<Transition name="focus-fade">
<div v-if="open && run" class="focus-backdrop" @click.self="$emit('close')">
<div class="focus-panel mc-surface-card" role="dialog" aria-modal="true">
<button
class="focus-close"
type="button"
:aria-label="t('backstage.actions.close')"
@click="$emit('close')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<path d="M6 6 L18 18 M18 6 L6 18"/>
</svg>
</button>
<!-- Hero: avatar (with status ring) + name + handle -->
<div class="focus-hero">
<div class="agent-avatar-wrap focus-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
<div class="agent-avatar focus-avatar" :style="avatarBgStyle(run)">
<SkillIcon
v-if="run.agentIcon"
:value="run.agentIcon"
:size="48"
fallback="🤖"
/>
<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-subtitle" v-if="run.username">@{{ run.username }}</div>
</div>
<!-- The one sentence: what it's doing right now -->
<p class="focus-saying">{{ humanSentence(run) }}</p>
<!-- Stuck callout -->
<div v-if="run.stuckReason" class="focus-callout">
{{ stuckCallout(run) }}
</div>
<!-- Time ring: 5-min activity window, center digits -->
<div class="time-ring" :class="{ 'time-ring-warn': !!run.stuckReason }">
<svg viewBox="0 0 120 120" class="ring-svg" aria-hidden="true">
<circle cx="60" cy="60" r="54" class="ring-track"/>
<circle
cx="60"
cy="60"
r="54"
class="ring-fill"
:style="{
strokeDasharray: RING_CIRCUMFERENCE,
strokeDashoffset: ringDashoffset(run.ageMs),
}"
/>
</svg>
<div class="ring-label">
<div class="ring-value">{{ formatAge(run.ageMs) }}</div>
<div class="ring-caption">{{ t('backstage.detail.runningFor') }}</div>
</div>
</div>
<!-- Secondary metadata -->
<dl class="focus-grid">
<div class="focus-row">
<dt>{{ t('backstage.detail.lastHeard') }}</dt>
<dd>{{ formatAge(run.msSinceLastEvent) }} {{ t('backstage.detail.ago') }}</dd>
</div>
<div class="focus-row">
<dt>{{ t('backstage.detail.audience') }}</dt>
<dd>
<span v-if="run.subscriberCount === 0" class="focus-warn">
{{ t('backstage.detail.noOneListening') }}
</span>
<span v-else>{{ t('backstage.detail.peopleListening', { n: run.subscriberCount }) }}</span>
</dd>
</div>
<div class="focus-row">
<dt>{{ t('backstage.detail.session') }}</dt>
<dd class="focus-id" :title="run.conversationId">#{{ shortId(run.conversationId) }}</dd>
</div>
</dl>
<!-- Helpers (subagents) -->
<div v-if="subagents.length > 0" class="focus-section">
<div class="focus-section-title">{{ t('backstage.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="🤖" />
<span v-else class="focus-sub-letter">{{ avatarLetter(sub) }}</span>
</div>
<div class="focus-sub-body">
<div class="focus-sub-name">{{ sub.agentName || sub.subagentId }}</div>
<div class="focus-sub-meta">
{{ sub.lastTool || sub.currentPhase || sub.status }} · {{ formatAge(sub.ageMs) }}
</div>
</div>
<button class="focus-sub-stop" type="button" @click="$emit('interrupt-sub', sub)">
{{ t('backstage.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') }}
</button>
<button class="focus-btn focus-btn-strong" type="button" @click="$emit('recycle', run)">
{{ t('backstage.actions.endIt') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
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'
const props = defineProps<{
open: boolean
run: BackstageRunCard | null
subagents: BackstageSubagentCard[]
}>()
const emit = defineEmits<{
close: []
stop: [run: BackstageRunCard]
recycle: [run: BackstageRunCard]
'interrupt-sub': [sub: BackstageSubagentCard]
}>()
const { t } = useI18n()
const {
avatarLetter,
avatarBgStyle,
ringClass,
dotTitle,
humanSentence,
stuckCallout,
formatAge,
} = useBackstageAgent()
// 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
// ring visibly fills as time passes Apple-watch activity ring metaphor.
// Beyond 5 minutes the ring just stays full; the colour shifts to warn when
// the agent is stuck so "full ring + warn" reads as "this has been at it
// long enough that you should look at it".
const RING_CIRCUMFERENCE = 2 * Math.PI * 54
function ringDashoffset(ageMs: number): number {
const p = Math.min(1, ageMs / 300_000)
return RING_CIRCUMFERENCE * (1 - p)
}
function shortId(id: string): string {
return id.length <= 8 ? id : id.slice(-8)
}
// Esc closes the panel keyboard parity with the click-on-backdrop affordance.
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
// Lock the body's scroll while the panel is open. The backdrop already
// catches scroll, but the underlying page would otherwise lurch when the
// modal is dismissed because grid changed in the meantime.
watch(
() => props.open,
open => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
},
)
onMounted(() => window.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<style scoped>
/* ===== Backdrop + entry/leave ===== */
.focus-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(20, 14, 10, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
overflow-y: auto;
}
html.dark .focus-backdrop {
background: rgba(0, 0, 0, 0.5);
}
.focus-fade-enter-active,
.focus-fade-leave-active {
transition: opacity 0.22s ease, backdrop-filter 0.22s ease, -webkit-backdrop-filter 0.22s ease;
}
.focus-fade-enter-active .focus-panel,
.focus-fade-leave-active .focus-panel {
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1),
opacity 0.22s ease;
}
.focus-fade-enter-from,
.focus-fade-leave-to {
opacity: 0;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
}
.focus-fade-enter-from .focus-panel,
.focus-fade-leave-to .focus-panel {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
/* ===== Panel ===== */
.focus-panel {
position: relative;
width: 100%;
max-width: 520px;
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 36px 32px 28px;
border-radius: 24px;
box-shadow: 0 32px 80px -20px rgba(0, 0, 0, 0.35),
0 8px 24px -8px rgba(0, 0, 0, 0.18);
}
.focus-close {
position: absolute;
top: 16px;
right: 16px;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: var(--mc-bg-muted);
color: var(--mc-text-tertiary);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.18s ease, color 0.18s ease;
padding: 0;
}
.focus-close:hover {
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
}
/* ===== Hero ===== */
.focus-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 22px;
padding-top: 4px;
}
.focus-avatar-wrap {
/* Inherits .agent-avatar-wrap from below; this just bumps the ring inset
to match the larger 64px focus avatar. */
}
.focus-avatar-wrap::before {
inset: 0;
border-radius: 23px; /* focus avatar 20 + 3 outset */
}
.focus-avatar {
width: 64px;
height: 64px;
border-radius: 20px;
}
.focus-avatar :deep(.skill-icon__glyph) {
font-size: 38px;
}
.focus-avatar-letter {
font-size: 26px;
}
.focus-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--mc-text-primary);
text-align: center;
margin-top: 8px;
line-height: 1.2;
}
.focus-subtitle {
font-size: 13px;
color: var(--mc-text-tertiary);
}
/* ===== The one sentence ===== */
.focus-saying {
font-size: 22px;
line-height: 1.45;
font-weight: 500;
letter-spacing: -0.015em;
color: var(--mc-text-primary);
text-align: center;
margin: 0 0 24px;
}
.focus-callout {
margin-bottom: 24px;
padding: 14px 16px;
border-radius: 14px;
background: hsla(20, 100%, 96%, 0.9);
color: hsl(20, 70%, 35%);
font-size: 13px;
line-height: 1.55;
border: 1px solid hsla(20, 80%, 55%, 0.22);
text-align: center;
}
html.dark .focus-callout {
background: hsla(20, 35%, 18%, 0.7);
color: hsl(28, 80%, 76%);
}
/* ===== Time ring =====
* Using brand tokens so the dial belongs to the panel instead of looking
* like a green sticker glued on. Healthy = mc-accent (deep teal in light,
* mint in dark). Stuck = mc-primary (terracotta). The inner glow is an
* accent-soft radial fill on a ::before, not an extra SVG element keeps
* the template untouched.
*/
.time-ring {
width: 132px;
height: 132px;
position: relative;
margin: 4px auto 28px;
}
.time-ring::before {
content: '';
position: absolute;
inset: 10px;
border-radius: 50%;
background: radial-gradient(
circle at center,
var(--mc-accent-soft) 0%,
rgba(220, 232, 228, 0) 72%
);
pointer-events: none;
transition: background 0.3s ease;
z-index: 0;
}
.time-ring-warn::before {
background: radial-gradient(
circle at center,
hsla(20, 100%, 92%, 0.7) 0%,
hsla(20, 100%, 92%, 0) 72%
);
}
html.dark .time-ring::before {
background: radial-gradient(
circle at center,
rgba(92, 166, 157, 0.14) 0%,
rgba(92, 166, 157, 0) 72%
);
}
html.dark .time-ring-warn::before {
background: radial-gradient(
circle at center,
rgba(235, 143, 101, 0.16) 0%,
rgba(235, 143, 101, 0) 72%
);
}
.ring-svg {
position: relative;
width: 100%;
height: 100%;
transform: rotate(-90deg); /* start from 12 o'clock */
display: block;
z-index: 1;
filter: drop-shadow(0 1px 2px rgba(28, 20, 16, 0.05));
}
.ring-track,
.ring-fill {
fill: none;
stroke-width: 4;
stroke-linecap: round;
}
.ring-track {
stroke: var(--mc-border-light);
opacity: 0.7;
}
.ring-fill {
stroke: var(--mc-accent);
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
}
.time-ring-warn .ring-fill {
stroke: var(--mc-primary);
}
.ring-label {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
/* Reserve a centered column so wider Chinese strings ("13 39 ")
don't crash into the ring; we let them shrink rather than wrap. */
padding: 0 14px;
}
.ring-value {
font-family: var(--mc-font-body), ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 17px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: -0.005em;
color: var(--mc-text-primary);
line-height: 1.1;
white-space: nowrap;
text-align: center;
}
.ring-caption {
font-size: 10px;
color: var(--mc-text-tertiary);
letter-spacing: 0.06em;
margin-top: 6px;
font-weight: 500;
}
/* ===== Secondary grid ===== */
.focus-grid {
margin: 0 0 8px;
border-top: 1px solid var(--mc-border-light);
}
.focus-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 13px 0;
border-bottom: 1px solid var(--mc-border-light);
margin: 0;
}
.focus-row dt {
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 0;
letter-spacing: 0.01em;
}
.focus-row dd {
font-size: 13.5px;
color: var(--mc-text-primary);
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums;
}
.focus-warn {
color: hsl(265, 50%, 50%);
}
html.dark .focus-warn {
color: hsl(265, 55%, 70%);
}
.focus-id {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 12px;
letter-spacing: 0.02em;
color: var(--mc-text-tertiary);
}
/* ===== Subagents ===== */
.focus-section {
margin-top: 16px;
}
.focus-section-title {
font-size: 11px;
letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
text-transform: uppercase;
margin-bottom: 10px;
font-weight: 600;
}
.focus-sub-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: var(--mc-bg-muted);
margin-bottom: 6px;
}
.focus-sub-icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
overflow: hidden;
font-weight: 700;
}
.focus-sub-icon :deep(.skill-icon__glyph) {
font-size: 18px;
}
.focus-sub-letter {
font-size: 13px;
}
.focus-sub-body {
flex: 1;
min-width: 0;
}
.focus-sub-name {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.focus-sub-meta {
font-size: 11px;
color: var(--mc-text-tertiary);
margin-top: 1px;
}
.focus-sub-stop {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--mc-border-light);
background: rgba(255, 255, 255, 0.6);
font-size: 11px;
cursor: pointer;
color: var(--mc-text-secondary);
font-family: inherit;
}
.focus-sub-stop:hover {
background: var(--mc-surface-overlay);
color: var(--mc-text-primary);
}
html.dark .focus-sub-stop {
background: rgba(255, 255, 255, 0.04);
}
/* ===== Action footer ===== */
.focus-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 24px;
}
.focus-btn {
padding: 9px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, color 0.2s ease;
border: none;
font-family: inherit;
}
.focus-btn-soft {
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
color: var(--mc-text-secondary);
}
.focus-btn-soft:hover {
background: var(--mc-surface-overlay);
color: var(--mc-text-primary);
}
.focus-btn-strong {
background: linear-gradient(135deg, hsl(20, 80%, 56%), hsl(15, 80%, 50%));
color: white;
box-shadow: 0 4px 14px -4px hsla(20, 80%, 50%, 0.45);
}
.focus-btn-strong:hover {
box-shadow: 0 6px 20px -4px hsla(20, 80%, 50%, 0.55);
transform: translateY(-1px);
}
/* ===== Status ring (parent + child share via composable; styles repeated
here because Vue scoped styles isolate per-component. Keyframe names get
auto-prefixed by Vue's scoped-style transform, so this won't collide
with the parent's identical declaration). ===== */
.agent-avatar-wrap {
position: relative;
flex-shrink: 0;
display: inline-flex;
padding: 4px;
margin: -4px;
}
.agent-avatar-wrap::before {
content: '';
position: absolute;
inset: 1px;
border-radius: 19px;
border: 2px solid transparent;
pointer-events: none;
z-index: 0;
will-change: transform, opacity;
}
.agent-avatar-wrap.ring-healthy::before {
border-color: hsl(155, 55%, 50%);
animation: focus-ring-breathe 3s ease-in-out infinite;
}
.agent-avatar-wrap.ring-stuck::before {
border-color: hsl(20, 80%, 55%);
animation: focus-ring-breathe-slow 6s ease-in-out infinite;
}
.agent-avatar-wrap.ring-orphan::before {
border-color: hsla(265, 50%, 60%, 0.6);
animation: focus-ring-breathe-faint 8s ease-in-out infinite;
}
.agent-avatar-wrap.ring-thinking::before {
border-color: transparent;
border-top-color: hsl(155, 55%, 55%);
border-right-color: hsla(155, 55%, 55%, 0.4);
animation: focus-ring-spin 1.6s linear infinite;
}
html.dark .agent-avatar-wrap.ring-healthy::before { border-color: hsl(155, 55%, 60%); }
html.dark .agent-avatar-wrap.ring-stuck::before { border-color: hsl(20, 75%, 62%); }
html.dark .agent-avatar-wrap.ring-orphan::before { border-color: hsla(265, 55%, 70%, 0.7); }
html.dark .agent-avatar-wrap.ring-thinking::before {
border-top-color: hsl(155, 55%, 65%);
border-right-color: hsla(155, 55%, 65%, 0.4);
}
@keyframes focus-ring-breathe {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 0.35; transform: scale(1.06); }
}
@keyframes focus-ring-breathe-slow {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(1.07); }
}
@keyframes focus-ring-breathe-faint {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.3; transform: scale(1.015); }
}
@keyframes focus-ring-spin {
to { transform: rotate(360deg); }
}
/* ===== Avatar surface (matches parent) ===== */
.agent-avatar {
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
letter-spacing: -0.02em;
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5),
0 1px 2px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
html.dark .agent-avatar {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.agent-avatar :deep(.skill-icon) {
width: 100% !important;
height: 100% !important;
display: flex;
align-items: center;
justify-content: center;
}
.agent-avatar :deep(.skill-icon__img) {
width: 60%;
height: 60%;
object-fit: contain;
}
.agent-avatar :deep(.skill-icon__svg svg) {
width: 60%;
height: 60%;
}
/* ===== Mobile ===== */
@media (max-width: 600px) {
.focus-panel {
max-width: 100%;
border-radius: 20px;
padding: 28px 22px 24px;
max-height: calc(100vh - 32px);
}
.focus-saying {
font-size: 19px;
}
.focus-title {
font-size: 20px;
}
.time-ring {
width: 116px;
height: 116px;
}
.ring-value {
font-size: 15px;
}
}
</style>

View File

@ -0,0 +1,102 @@
/**
* Shared display helpers for the Backstage page 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.
*/
import { useI18n } from 'vue-i18n'
import type { BackstageRunCard, BackstageSubagentCard } from '@/api'
type AnyRun = BackstageRunCard | BackstageSubagentCard
export function useBackstageAgent() {
const { t } = useI18n()
function avatarLetter(run: AnyRun): string {
const name = run.agentName || (run as any).username || (run as any).subagentId || '?'
return name.charAt(0).toUpperCase()
}
/**
* Soft, low-saturation gradient seeded from agent name. Even when an icon
* is present we use this as the avatar surface Apple's wallpaper-behind-
* icon trick, not a billboard.
*/
function avatarBgStyle(run: AnyRun) {
const seed = run.agentName || (run as any).conversationId || (run as any).subagentId || 'x'
let h = 0
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
const hue = Math.abs(h) % 360
return {
background: `linear-gradient(140deg, hsl(${hue}, 55%, 92%), hsl(${(hue + 30) % 360}, 50%, 86%))`,
color: `hsl(${hue}, 45%, 28%)`,
}
}
/**
* Status ring classification four moods:
* - thinking: alive but no first token yet (and still young) spinner arc
* - stuck: explicit stuckReason slow, saturated breathing
* - orphan: nobody listening faint, almost-still
* - healthy: streaming or settled gentle 3s breath
*
* Subagent cards lack stuck/orphan/firstToken flags, so they fall through
* 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
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 }
return { 'ring-healthy': true }
}
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')
}
function humanSentence(run: BackstageRunCard): string {
if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilent', { tool: run.runningToolName || t('backstage.aTool') })
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.usingTool', { tool: run.runningToolName })
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 formatAge(ms: number): string {
if (ms < 1500) return t('backstage.time.justNow')
const sec = Math.floor(ms / 1000)
if (sec < 60) return t('backstage.time.seconds', { n: sec })
const min = Math.floor(sec / 60)
if (min < 60) return t('backstage.time.minutes', { n: min, s: sec % 60 })
const hr = Math.floor(min / 60)
return t('backstage.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) })
}
return {
avatarLetter,
avatarBgStyle,
ringClass,
dotTitle,
humanSentence,
stuckCallout,
formatAge,
}
}

View File

@ -373,6 +373,7 @@ export default {
noOneListening: 'No one watching the chat right now.',
peopleListening: '{n} listening',
helpers: 'Helpers',
session: 'Session',
},
actions: {
live: 'Live',
@ -384,6 +385,7 @@ export default {
stopHint: 'Ask it to wind down at the next checkpoint.',
endIt: 'End it',
endHint: 'Force it to stop now.',
close: 'Close',
},
confirm: {
stopTitle: 'Stop this agent?',

View File

@ -1553,6 +1553,7 @@ export default {
noOneListening: '当前没有人在看这个对话。',
peopleListening: '{n} 人在看',
helpers: '帮手',
session: '会话',
},
actions: {
live: '实时',
@ -1564,6 +1565,7 @@ export default {
stopHint: '让它在下个检查点停下来。',
endIt: '强制结束',
endHint: '立即强制停止。',
close: '关闭',
},
confirm: {
stopTitle: '让这个智能体停下吗?',

View File

@ -80,36 +80,31 @@
:class="cardClass(run)"
@click="openDetail(run)"
>
<!-- Top: avatar + name + breathing dot -->
<!-- Top: avatar (with status ring) + name -->
<div class="agent-card-top">
<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 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 class="breathing-dot-wrap" :title="dotTitle(run)">
<span class="breathing-dot" :class="dotClass(run)"></span>
</div>
</div>
<!-- Single human sentence -->
<div class="agent-saying">{{ humanSentence(run) }}</div>
<!-- Meta + soft progress, only when meaningful -->
<!-- Meta: just the time + orphan hint (id moved to detail) -->
<div class="agent-meta-row">
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
<span class="meta-id" :title="run.conversationId">#{{ shortId(run.conversationId) }}</span>
<span v-if="run.subagentCount > 0" class="meta-pill">
{{ t('backstage.subagentsBadge', { n: run.subagentCount }) }}
</span>
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('backstage.orphanHint')">
{{ t('backstage.orphan') }}
</span>
@ -118,19 +113,36 @@
<div class="bar-fill" :style="progressFillStyle(run)"></div>
</div>
<!-- Foot: action hierarchy -->
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
<div class="agent-card-foot">
<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 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>
@ -138,80 +150,17 @@
</div>
</div>
<!-- Detail drawer -->
<el-drawer
v-model="drawerOpen"
:show-close="true"
:with-header="false"
direction="rtl"
size="440px"
>
<div v-if="detail" class="detail-pane">
<header class="detail-header">
<div class="agent-avatar detail-avatar" :style="avatarBgStyle(detail)">
<SkillIcon
v-if="detail.agentIcon"
:value="detail.agentIcon"
:size="42"
fallback="🤖"
/>
<span v-else class="agent-avatar-letter">{{ avatarLetter(detail) }}</span>
</div>
<div class="detail-title-block">
<div class="detail-title">{{ detail.agentName || t('backstage.unknownAgent') }}</div>
<div class="detail-subtitle" v-if="detail.username">@{{ detail.username }}</div>
</div>
<span class="breathing-dot detail-dot" :class="dotClass(detail)" :title="dotTitle(detail)"></span>
</header>
<p class="detail-saying">{{ humanSentence(detail) }}</p>
<div v-if="detail.stuckReason" class="detail-callout">
{{ stuckCallout(detail) }}
</div>
<dl class="detail-grid">
<div class="detail-row">
<dt>{{ t('backstage.detail.runningFor') }}</dt>
<dd>{{ formatAge(detail.ageMs) }}</dd>
</div>
<div class="detail-row">
<dt>{{ t('backstage.detail.lastHeard') }}</dt>
<dd>{{ formatAge(detail.msSinceLastEvent) }} {{ t('backstage.detail.ago') }}</dd>
</div>
<div class="detail-row">
<dt>{{ t('backstage.detail.audience') }}</dt>
<dd>
<span v-if="detail.subscriberCount === 0" class="detail-warn">
{{ t('backstage.detail.noOneListening') }}
</span>
<span v-else>{{ t('backstage.detail.peopleListening', { n: detail.subscriberCount }) }}</span>
</dd>
</div>
</dl>
<div v-if="childrenOf(detail).length > 0" class="detail-section">
<div class="detail-section-title">{{ t('backstage.detail.helpers') }}</div>
<div v-for="sub in childrenOf(detail)" :key="sub.subagentId" class="subagent-row">
<div class="subagent-icon" :style="avatarBgStyle(sub)">
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="20" fallback="🤖" />
<span v-else class="agent-avatar-letter sub-letter">{{ avatarLetter(sub) }}</span>
</div>
<div class="subagent-body">
<div class="subagent-name">{{ sub.agentName || sub.subagentId }}</div>
<div class="subagent-meta">{{ sub.lastTool || sub.currentPhase || sub.status }} · {{ formatAge(sub.ageMs) }}</div>
</div>
<button class="subagent-stop" @click="confirmInterruptSub(sub)">{{ t('backstage.actions.stop') }}</button>
</div>
</div>
<div class="detail-actions">
<button class="btn-soft" @click="confirmStop(detail)">{{ t('backstage.actions.stop') }}</button>
<button class="btn-strong" @click="confirmRecycle(detail)">{{ t('backstage.actions.endIt') }}</button>
</div>
</div>
</el-drawer>
</div>
<BackstageFocusPanel
:open="drawerOpen"
:run="detail"
:subagents="detail ? childrenOf(detail) : []"
@close="closeDetail"
@stop="confirmStop"
@recycle="confirmRecycle"
@interrupt-sub="confirmInterruptSub"
/>
</template>
<script setup lang="ts">
@ -219,9 +168,19 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import SkillIcon from '@/components/common/SkillIcon.vue'
import BackstageFocusPanel from '@/components/backstage/BackstageFocusPanel.vue'
import { useBackstageAgent } from '@/composables/useBackstageAgent'
import { backstageApi, type BackstageSnapshot, type BackstageRunCard, type BackstageSubagentCard } from '@/api'
const { t } = useI18n()
const {
avatarLetter,
avatarBgStyle,
ringClass,
dotTitle,
humanSentence,
formatAge,
} = useBackstageAgent()
type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
@ -307,16 +266,6 @@ const visibleRuns = computed<BackstageRunCard[]>(() => {
}
})
/**
* Last 8 chars of a conversation id, rendered as `#a1b2c3d4` gives each
* card a stable serial without leaking the full UUID. Falls back gracefully
* for short ids.
*/
function shortId(id: string | null | undefined): string {
if (!id) return '——'
return id.length <= 8 ? id : id.slice(-8)
}
// 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
// doesn't go inexplicably empty.
@ -326,27 +275,6 @@ watch(filterOptions, opts => {
}
})
function avatarLetter(run: BackstageRunCard | BackstageSubagentCard): string {
const name = run.agentName || (run as any).username || (run as any).subagentId || '?'
return name.charAt(0).toUpperCase()
}
/**
* Soft, low-saturation gradient seeded from agent name. Even when an icon
* is present we use this as the avatar surface Apple's wallpaper-behind-
* icon trick, not a billboard.
*/
function avatarBgStyle(run: BackstageRunCard | BackstageSubagentCard) {
const seed = run.agentName || (run as any).conversationId || (run as any).subagentId || 'x'
let h = 0
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0
const hue = Math.abs(h) % 360
return {
background: `linear-gradient(140deg, hsl(${hue}, 55%, 92%), hsl(${(hue + 30) % 360}, 50%, 86%))`,
color: `hsl(${hue}, 45%, 28%)`,
}
}
function cardClass(run: BackstageRunCard) {
return {
'is-stuck': !!run.stuckReason,
@ -355,39 +283,6 @@ function cardClass(run: BackstageRunCard) {
}
}
function dotClass(run: BackstageRunCard | BackstageSubagentCard) {
const r = run as BackstageRunCard
if (r.stuckReason) return 'dot-stuck'
if (r.orphan) return 'dot-orphan'
return 'dot-healthy'
}
function dotTitle(run: BackstageRunCard | BackstageSubagentCard): 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')
}
function humanSentence(run: BackstageRunCard): string {
if (run.stuckReason === 'tool_silent') return t('backstage.saying.toolSilent', { tool: run.runningToolName || t('backstage.aTool') })
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.usingTool', { tool: run.runningToolName })
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 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) })
}
/**
* Bar only appears once a run has been going long enough that elapsed time
* starts to matter. Under 30s is "barely started" rendering 1px of bar
@ -403,25 +298,27 @@ function progressFillStyle(run: BackstageRunCard) {
return { width: `${Math.max(4, pct)}%` }
}
function formatAge(ms: number): string {
if (ms < 1500) return t('backstage.time.justNow')
const sec = Math.floor(ms / 1000)
if (sec < 60) return t('backstage.time.seconds', { n: sec })
const min = Math.floor(sec / 60)
if (min < 60) return t('backstage.time.minutes', { n: min, s: sec % 60 })
const hr = Math.floor(min / 60)
return t('backstage.time.hours', { n: hr, m: min % 60 })
}
function childrenOf(run: BackstageRunCard): BackstageSubagentCard[] {
return snapshot.value?.subagents.filter(s => s.parentConversationId === run.conversationId) ?? []
}
function openDetail(run: BackstageRunCard) {
// 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
// which run is bound.
if (drawerOpen.value && detail.value?.conversationId === run.conversationId) {
drawerOpen.value = false
return
}
detail.value = run
drawerOpen.value = true
}
function closeDetail() {
drawerOpen.value = false
}
async function refresh() {
try {
const res: any = await backstageApi.snapshot()
@ -773,7 +670,7 @@ html.dark .agent-card.is-stuck {
cursor: default;
}
/* ===== Top row: avatar + name + dot ===== */
/* ===== Top row: avatar (with status ring) + name ===== */
.agent-card-top {
display: flex;
align-items: center;
@ -781,6 +678,100 @@ html.dark .agent-card.is-stuck {
margin-bottom: 16px;
}
/*
* Status ring lives on a wrap because the inner avatar must keep
* `overflow: hidden` (to clip its icon). The ring renders via ::before
* on the wrap, free to extend beyond the avatar's box.
*/
.agent-avatar-wrap {
position: relative;
flex-shrink: 0;
display: inline-flex;
/* Reserve room around the avatar so the ring breath doesn't get clipped
by the parent flex item bounds. */
padding: 4px;
margin: -4px;
}
.agent-avatar-wrap::before {
content: '';
position: absolute;
inset: 1px;
border-radius: 19px; /* avatar 16 + 3 outset */
border: 2px solid transparent;
pointer-events: none;
z-index: 0;
/* Both transform (scale) and opacity get animated; will-change hints the
compositor so we don't repaint the whole card on each frame. */
will-change: transform, opacity;
}
.agent-avatar-wrap.ring-healthy::before {
border-color: hsl(155, 55%, 50%);
animation: ring-breathe 3s ease-in-out infinite;
}
.agent-avatar-wrap.ring-stuck::before {
border-color: hsl(20, 80%, 55%);
border-width: 2px;
animation: ring-breathe-slow 6s ease-in-out infinite;
}
.agent-avatar-wrap.ring-orphan::before {
border-color: hsla(265, 50%, 60%, 0.6);
animation: ring-breathe-faint 8s ease-in-out infinite;
}
/*
* Thinking: the agent is alive but hasn't streamed yet. A spinner-style
* arc top + right border tinted, the rest transparent slowly rotates.
* Communicates "working on something" without committing to a colour
* outcome. Once the first token lands, ringClass flips to ring-healthy.
*/
.agent-avatar-wrap.ring-thinking::before {
border-color: transparent;
border-top-color: hsl(155, 55%, 55%);
border-right-color: hsla(155, 55%, 55%, 0.4);
animation: ring-spin 1.6s linear infinite;
}
/* Dark-mode tuning — rings need slightly higher lightness to sit on warm dark surfaces */
html.dark .agent-avatar-wrap.ring-healthy::before {
border-color: hsl(155, 55%, 60%);
}
html.dark .agent-avatar-wrap.ring-stuck::before {
border-color: hsl(20, 75%, 62%);
}
html.dark .agent-avatar-wrap.ring-orphan::before {
border-color: hsla(265, 55%, 70%, 0.7);
}
html.dark .agent-avatar-wrap.ring-thinking::before {
border-top-color: hsl(155, 55%, 65%);
border-right-color: hsla(155, 55%, 65%, 0.4);
}
@keyframes ring-breathe {
0%, 100% { opacity: 0.85; transform: scale(1); }
50% { opacity: 0.35; transform: scale(1.06); }
}
@keyframes ring-breathe-slow {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(1.07); }
}
@keyframes ring-breathe-faint {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.3; transform: scale(1.015); }
}
@keyframes ring-spin {
to { transform: rotate(360deg); }
}
.agent-avatar {
width: 50px;
height: 50px;
@ -853,78 +844,16 @@ html.dark .agent-avatar {
letter-spacing: 0.01em;
}
/* ===== Breathing dot ===== */
.breathing-dot-wrap {
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.breathing-dot {
width: 9px;
height: 9px;
border-radius: 50%;
display: block;
position: relative;
}
.breathing-dot.dot-healthy {
background: hsl(155, 55%, 50%);
animation: breathe-healthy 3s ease-in-out infinite;
}
.breathing-dot.dot-healthy::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsla(155, 55%, 50%, 0.3);
animation: breathe-pulse 3s ease-in-out infinite;
}
.breathing-dot.dot-stuck {
background: hsl(20, 80%, 55%);
animation: breathe-slow 4.5s ease-in-out infinite;
}
.breathing-dot.dot-stuck::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
background: hsla(20, 80%, 55%, 0.32);
animation: breathe-pulse-slow 4.5s ease-in-out infinite;
}
.breathing-dot.dot-orphan {
background: hsl(265, 55%, 62%);
animation: breathe-healthy 3.5s ease-in-out infinite;
opacity: 0.8;
}
/*
* The empty-state orb still wants its slow breath. Keep this single keyframe
* around even though the per-card breathing dots are gone it's used by
* `.empty-orb` below.
*/
@keyframes breathe-healthy {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(0.85); }
}
@keyframes breathe-slow {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.78); }
}
@keyframes breathe-pulse {
0%, 100% { opacity: 0; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.5); }
}
@keyframes breathe-pulse-slow {
0%, 100% { opacity: 0; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.7); }
}
/* ===== Saying ===== */
.agent-saying {
font-size: 14.5px;
@ -959,25 +888,6 @@ html.dark .agent-card.is-stuck .agent-saying {
letter-spacing: 0.01em;
}
.meta-id {
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 10.5px;
letter-spacing: 0.02em;
color: var(--mc-text-tertiary);
padding: 1px 7px;
border-radius: 4px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
opacity: 0.85;
cursor: default;
}
html.dark .meta-id {
/* Slightly lift contrast on the warm-dark surface */
background: rgba(255, 255, 255, 0.04);
border-color: var(--mc-border-light);
}
.meta-pill {
padding: 2px 9px;
border-radius: 999px;
@ -1012,14 +922,88 @@ html.dark .meta-id {
background: linear-gradient(90deg, hsl(28, 80%, 60%), hsl(20, 80%, 55%));
}
/* ===== Foot ===== */
/* ===== Foot: subagent stack (left) + actions (right) ===== */
.agent-card-foot {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
margin-top: 4px;
}
.card-foot-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
/*
* 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 {
display: inline-flex;
align-items: center;
}
.subagent-chip {
width: 22px;
height: 22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
letter-spacing: -0.02em;
border: 2px solid var(--mc-bg-elevated);
margin-left: -7px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
cursor: default;
}
.subagent-chip:first-child {
margin-left: 0;
}
.subagent-chip :deep(.skill-icon) {
width: 100% !important;
height: 100% !important;
display: flex;
align-items: center;
justify-content: center;
}
.subagent-chip :deep(.skill-icon__glyph) {
font-size: 12px;
line-height: 1;
}
.sub-chip-letter {
font-size: 10px;
line-height: 1;
}
.subagent-overflow {
background: var(--mc-bg-muted) !important;
color: var(--mc-text-tertiary);
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.02em;
}
html.dark .subagent-chip {
border-color: var(--mc-bg-elevated);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
html.dark .subagent-overflow {
background: rgba(255, 255, 255, 0.06) !important;
}
.card-action {
padding: 6px 14px;
font-size: 12.5px;
@ -1081,229 +1065,4 @@ html.dark .card-action-strong {
color: var(--mc-text-tertiary);
}
/* ===== Detail drawer ===== */
.detail-pane {
padding: 28px 28px 24px;
}
.detail-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 20px;
}
.detail-avatar {
width: 56px;
height: 56px;
border-radius: 18px;
}
.detail-avatar :deep(.skill-icon__glyph) {
font-size: 32px;
}
.detail-avatar .agent-avatar-letter {
font-size: 22px;
}
.detail-title-block {
flex: 1;
min-width: 0;
}
.detail-title {
font-size: 19px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--mc-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.detail-subtitle {
font-size: 12.5px;
color: var(--mc-text-tertiary);
margin-top: 2px;
}
.detail-dot {
flex-shrink: 0;
}
.detail-saying {
font-size: 15px;
line-height: 1.6;
color: var(--mc-text-primary);
margin: 0 0 18px;
letter-spacing: -0.005em;
}
.detail-warn {
color: hsl(265, 50%, 50%);
}
.detail-callout {
margin-bottom: 22px;
padding: 14px 16px;
border-radius: 14px;
background: hsla(20, 100%, 96%, 0.9);
color: hsl(20, 70%, 35%);
font-size: 13px;
line-height: 1.55;
border: 1px solid hsla(20, 80%, 55%, 0.22);
}
html.dark .detail-callout {
background: hsla(20, 35%, 18%, 0.7);
color: hsl(28, 80%, 76%);
}
.detail-grid {
margin: 0 0 24px;
border-top: 1px solid var(--mc-border-light);
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 13px 0;
border-bottom: 1px solid var(--mc-border-light);
margin: 0;
}
.detail-row dt {
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 0;
letter-spacing: 0.01em;
}
.detail-row dd {
font-size: 13.5px;
color: var(--mc-text-primary);
margin: 0;
text-align: right;
font-variant-numeric: tabular-nums;
}
.detail-section {
margin-top: 8px;
}
.detail-section-title {
font-size: 11px;
letter-spacing: 0.08em;
color: var(--mc-text-tertiary);
text-transform: uppercase;
margin-bottom: 10px;
font-weight: 600;
}
.subagent-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: var(--mc-bg-muted);
margin-bottom: 6px;
}
.subagent-icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
overflow: hidden;
}
.subagent-icon :deep(.skill-icon__glyph) {
font-size: 18px;
}
.sub-letter {
font-size: 13px;
}
.subagent-body {
flex: 1;
min-width: 0;
}
.subagent-name {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subagent-meta {
font-size: 11px;
color: var(--mc-text-tertiary);
margin-top: 1px;
}
.subagent-stop {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--mc-border-light);
background: rgba(255, 255, 255, 0.6);
font-size: 11px;
cursor: pointer;
color: var(--mc-text-secondary);
}
.subagent-stop:hover {
background: var(--mc-surface-overlay);
}
.detail-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 28px;
}
.btn-soft,
.btn-strong {
padding: 9px 20px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-soft {
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
color: var(--mc-text-secondary);
}
.btn-soft:hover {
background: var(--mc-surface-overlay);
color: var(--mc-text-primary);
}
.btn-strong {
background: linear-gradient(135deg, hsl(20, 80%, 56%), hsl(15, 80%, 50%));
color: white;
box-shadow: 0 4px 14px -4px hsla(20, 80%, 50%, 0.45);
}
.btn-strong:hover {
box-shadow: 0 6px 20px -4px hsla(20, 80%, 50%, 0.55);
transform: translateY(-1px);
}
</style>