mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(backstage): avatar status ring, hero focus panel, brand-tone time dial
This commit is contained in:
parent
4bf4abd984
commit
7708e26851
766
mateclaw-ui/src/components/backstage/BackstageFocusPanel.vue
Normal file
766
mateclaw-ui/src/components/backstage/BackstageFocusPanel.vue
Normal 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>
|
||||
102
mateclaw-ui/src/composables/useBackstageAgent.ts
Normal file
102
mateclaw-ui/src/composables/useBackstageAgent.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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?',
|
||||
|
||||
@ -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: '让这个智能体停下吗?',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user