mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
Live lifecycle board (grid<->board toggle) plus an assignee-swimlane plan board that groups follow-up re-runs of one goal into a single xN card. Custom right-side detail/goal panels with markdown output. Fixes plans being persisted under the per-run trace id so the board actually populates. Closes #385
1216 lines
34 KiB
Vue
1216 lines
34 KiB
Vue
<template>
|
|
<div class="live-panel">
|
|
<!-- Toolbar: live toggle (left) + status filters + sweep (right) -->
|
|
<div class="live-toolbar">
|
|
<button
|
|
class="chip-btn"
|
|
:class="{ 'is-paused': !autoRefresh }"
|
|
:title="autoRefresh ? t('live.actions.pauseRefresh') : t('live.actions.resumeRefresh')"
|
|
@click="toggleAutoRefresh"
|
|
>
|
|
<span class="chip-pulse" v-if="autoRefresh"></span>
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
</svg>
|
|
<span>{{ autoRefresh ? t('live.actions.live') : t('live.actions.paused') }}</span>
|
|
</button>
|
|
|
|
<div v-if="showFilterRow && layout === 'grid'" class="filter-row">
|
|
<button
|
|
v-for="opt in filterOptions"
|
|
:key="opt.key"
|
|
class="filter-chip"
|
|
:class="{ 'is-active': activeFilter === opt.key, [`tone-${opt.tone}`]: true }"
|
|
@click="activeFilter = opt.key"
|
|
>
|
|
<span class="filter-chip-label">{{ opt.label }}</span>
|
|
<span class="filter-chip-count">{{ opt.count }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="toolbar-spacer"></div>
|
|
|
|
<div class="layout-toggle" role="group">
|
|
<button
|
|
class="layout-seg"
|
|
:class="{ 'is-active': layout === 'grid' }"
|
|
:title="t('live.layout.gridHint')"
|
|
@click="setLayout('grid')"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
|
<span>{{ t('live.layout.grid') }}</span>
|
|
</button>
|
|
<button
|
|
class="layout-seg"
|
|
:class="{ 'is-active': layout === 'board' }"
|
|
:title="t('live.layout.boardHint')"
|
|
@click="setLayout('board')"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="6" height="18" rx="1"/><rect x="10" y="3" width="6" height="11" rx="1"/><rect x="17" y="3" width="4" height="7" rx="1"/></svg>
|
|
<span>{{ t('live.layout.board') }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
v-if="(snapshot?.summary?.stuck ?? 0) > 0"
|
|
class="chip-btn chip-btn-warm"
|
|
@click="confirmSweep"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 6h18"/>
|
|
<path d="M19 6l-1.2 14a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8L5 6"/>
|
|
<path d="M10 11v6M14 11v6"/>
|
|
</svg>
|
|
{{ t('live.actions.tidyUp') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="isInitialLoading" class="cards-grid">
|
|
<div v-for="i in 3" :key="i" class="agent-card mc-surface-card agent-card-skeleton">
|
|
<el-skeleton :rows="2" animated />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lifecycle board: runs + goals across status columns -->
|
|
<LiveBoard
|
|
v-else-if="layout === 'board'"
|
|
:runs="snapshot?.runs ?? []"
|
|
:summary="snapshot?.summary ?? null"
|
|
:goal-by-conv="goalByConv"
|
|
:done-goals="doneGoals"
|
|
:failed-goals="failedGoals"
|
|
@open="openDetail"
|
|
@stop="confirmStop"
|
|
@recycle="confirmRecycle"
|
|
/>
|
|
|
|
<!-- Empty: nothing to see -->
|
|
<div v-else-if="snapshot && snapshot.runs.length === 0" class="empty-still">
|
|
<div class="empty-orb"></div>
|
|
<div class="empty-line">{{ t('live.empty.allQuiet') }}</div>
|
|
<div class="empty-hint">{{ t('live.empty.hint') }}</div>
|
|
</div>
|
|
|
|
<!-- Active runs -->
|
|
<div v-else class="cards-grid">
|
|
<article
|
|
v-for="run in visibleRuns"
|
|
:key="run.conversationId"
|
|
class="agent-card mc-surface-card"
|
|
:class="cardClass(run)"
|
|
@click="openDetail(run)"
|
|
>
|
|
<!-- Top: avatar (with status ring) + name -->
|
|
<div class="agent-card-top">
|
|
<div class="agent-avatar-wrap" :class="ringClass(run)" :title="dotTitle(run)">
|
|
<div class="agent-avatar" :style="avatarBgStyle(run)">
|
|
<SkillIcon
|
|
v-if="run.agentIcon"
|
|
:value="run.agentIcon"
|
|
:size="34"
|
|
fallback="🤖"
|
|
/>
|
|
<span v-else class="agent-avatar-letter">{{ avatarLetter(run) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="agent-id">
|
|
<div class="agent-name">{{ run.agentName || t('live.unknownAgent') }}</div>
|
|
<div class="agent-owner" v-if="run.username">@{{ run.username }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status sentence + (when present) the tool chip standing
|
|
on its own so a long tool name doesn't get ellipsis-eaten. -->
|
|
<div class="agent-saying-row">
|
|
<span class="agent-saying">{{ humanSentence(run) }}</span>
|
|
<span v-if="run.runningToolName" class="tool-chip" :title="run.runningToolName">
|
|
{{ run.runningToolName }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Meta: just the time + orphan hint (id moved to detail) -->
|
|
<div class="agent-meta-row">
|
|
<span class="meta-time">{{ formatAge(run.ageMs) }}</span>
|
|
<span v-if="run.orphan && !run.stuckReason" class="meta-pill meta-pill-orphan" :title="t('live.orphanHint')">
|
|
{{ t('live.orphan') }}
|
|
</span>
|
|
</div>
|
|
<div class="agent-bar" v-if="showBar(run)">
|
|
<div class="bar-fill" :style="progressFillStyle(run)"></div>
|
|
</div>
|
|
|
|
<!-- Foot: subagent stack (left) + action hierarchy (right) -->
|
|
<div class="agent-card-foot">
|
|
<div class="subagent-stack" v-if="childrenOf(run).length > 0">
|
|
<div
|
|
v-for="sub in childrenOf(run).slice(0, 3)"
|
|
:key="sub.subagentId"
|
|
class="subagent-chip"
|
|
:style="avatarBgStyle(sub)"
|
|
:title="sub.agentName || sub.subagentId"
|
|
>
|
|
<SkillIcon v-if="sub.agentIcon" :value="sub.agentIcon" :size="14" fallback="🤖" />
|
|
<span v-else class="sub-chip-letter">{{ avatarLetter(sub) }}</span>
|
|
</div>
|
|
<div v-if="childrenOf(run).length > 3" class="subagent-chip subagent-overflow">
|
|
+{{ childrenOf(run).length - 3 }}
|
|
</div>
|
|
</div>
|
|
<div class="card-foot-actions">
|
|
<button
|
|
class="card-action card-action-soft"
|
|
@click.stop="confirmStop(run)"
|
|
:title="t('live.actions.stopHint')"
|
|
>{{ t('live.actions.stop') }}</button>
|
|
<button
|
|
v-if="run.stuckReason"
|
|
class="card-action card-action-strong"
|
|
@click.stop="confirmRecycle(run)"
|
|
:title="t('live.actions.endHint')"
|
|
>{{ t('live.actions.endIt') }}</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
<LiveFocusPanel
|
|
:open="drawerOpen"
|
|
:run="detail"
|
|
:subagents="detail ? childrenOf(detail) : []"
|
|
@close="closeDetail"
|
|
@stop="confirmStop"
|
|
@recycle="confirmRecycle"
|
|
@interrupt-sub="confirmInterruptSub"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { mcToast } from '@/composables/useMcToast'
|
|
import SkillIcon from '@/components/common/SkillIcon.vue'
|
|
import LiveFocusPanel from '@/components/live/LiveFocusPanel.vue'
|
|
import LiveBoard from '@/components/live/LiveBoard.vue'
|
|
import { useLiveAgent } from '@/composables/useLiveAgent'
|
|
import { mcConfirm } from '@/components/common/useConfirm'
|
|
import { liveApi, goalApi, type LiveSnapshot, type LiveRunCard, type LiveSubagentCard, type Goal } from '@/api'
|
|
|
|
const { t } = useI18n()
|
|
const {
|
|
avatarLetter,
|
|
avatarBgStyle,
|
|
ringClass,
|
|
dotTitle,
|
|
humanSentence,
|
|
formatAge,
|
|
} = useLiveAgent()
|
|
|
|
type FilterKey = 'all' | 'working' | 'attention' | 'quiet'
|
|
|
|
const snapshot = ref<LiveSnapshot | null>(null)
|
|
const isInitialLoading = ref(true)
|
|
const autoRefresh = ref(true)
|
|
const drawerOpen = ref(false)
|
|
const detail = ref<LiveRunCard | null>(null)
|
|
const activeFilter = ref<FilterKey>('all')
|
|
let timer: ReturnType<typeof setInterval> | null = null
|
|
|
|
// ===== Lifecycle board mode =====
|
|
// Same snapshot, laid out across run/goal lifecycle columns. Goals are fetched
|
|
// lazily (only once the board is shown) and refreshed alongside the snapshot.
|
|
const layout = ref<'grid' | 'board'>('grid')
|
|
const goalsActive = ref<Goal[]>([])
|
|
const doneGoals = ref<Goal[]>([])
|
|
const failedGoals = ref<Goal[]>([])
|
|
|
|
const goalByConv = computed<Record<string, Goal>>(() => {
|
|
const map: Record<string, Goal> = {}
|
|
for (const g of goalsActive.value) map[g.conversationId] = g
|
|
return map
|
|
})
|
|
|
|
function setLayout(next: 'grid' | 'board') {
|
|
if (layout.value === next) return
|
|
layout.value = next
|
|
if (next === 'board') loadGoals()
|
|
}
|
|
|
|
async function loadGoals() {
|
|
// Best-effort: the board still renders its run columns without goals.
|
|
try {
|
|
const [active, done, failed] = await Promise.all([
|
|
goalApi.list({ status: 'active', limit: 100 }),
|
|
goalApi.list({ status: 'completed', limit: 50 }),
|
|
goalApi.list({ status: 'exhausted', limit: 50 }),
|
|
])
|
|
goalsActive.value = ((active as any)?.data ?? []) as Goal[]
|
|
doneGoals.value = ((done as any)?.data ?? []) as Goal[]
|
|
failedGoals.value = ((failed as any)?.data ?? []) as Goal[]
|
|
} catch {
|
|
/* leave whatever we had; columns degrade to empty */
|
|
}
|
|
}
|
|
|
|
function isWorking(r: LiveRunCard): boolean {
|
|
return !r.stuckReason && !r.orphan
|
|
}
|
|
function isAttention(r: LiveRunCard): boolean {
|
|
return !!r.stuckReason
|
|
}
|
|
function isQuiet(r: LiveRunCard): boolean {
|
|
return r.orphan && !r.stuckReason
|
|
}
|
|
|
|
const filterOptions = computed(() => {
|
|
const runs = snapshot.value?.runs ?? []
|
|
const counts = {
|
|
all: runs.length,
|
|
working: runs.filter(isWorking).length,
|
|
attention: runs.filter(isAttention).length,
|
|
quiet: runs.filter(isQuiet).length,
|
|
}
|
|
// Always show All; only show secondary chips that have at least one match —
|
|
// an empty chip is dead pixels.
|
|
const opts: { key: FilterKey; label: string; count: number; tone: string }[] = [
|
|
{ key: 'all', label: t('live.filters.all'), count: counts.all, tone: 'neutral' },
|
|
]
|
|
if (counts.working > 0) opts.push({ key: 'working', label: t('live.filters.working'), count: counts.working, tone: 'good' })
|
|
if (counts.attention > 0) opts.push({ key: 'attention', label: t('live.filters.attention'), count: counts.attention, tone: 'warn' })
|
|
if (counts.quiet > 0) opts.push({ key: 'quiet', label: t('live.filters.quiet'), count: counts.quiet, tone: 'muted' })
|
|
return opts
|
|
})
|
|
|
|
/**
|
|
* Filter row only earns its keep when there's variety to filter.
|
|
* Pure-healthy fleets shouldn't pay the visual tax.
|
|
*/
|
|
const showFilterRow = computed(() => {
|
|
const s = snapshot.value?.summary
|
|
if (!s || s.running === 0) return false
|
|
return s.stuck > 0 || s.orphan > 0
|
|
})
|
|
|
|
/**
|
|
* Triage order: stuck first (you have to do something), then orphan (a run
|
|
* that nobody is watching), then working (healthy & boring). Within each
|
|
* tier, the older run wins — a 5-minute stuck run is more urgent than one
|
|
* stuck for 8 seconds.
|
|
*
|
|
* Without this, runs are listed in whatever order the snapshot returned
|
|
* them, and the one card you actually need to look at can be 4 rows down.
|
|
*/
|
|
function tierOf(r: LiveRunCard): number {
|
|
if (r.stuckReason) return 0
|
|
if (r.orphan) return 1
|
|
return 2
|
|
}
|
|
|
|
const visibleRuns = computed<LiveRunCard[]>(() => {
|
|
const runs = snapshot.value?.runs ?? []
|
|
const filtered = (() => {
|
|
switch (activeFilter.value) {
|
|
case 'working': return runs.filter(isWorking)
|
|
case 'attention': return runs.filter(isAttention)
|
|
case 'quiet': return runs.filter(isQuiet)
|
|
default: return runs
|
|
}
|
|
})()
|
|
return [...filtered].sort((a, b) => {
|
|
const ta = tierOf(a)
|
|
const tb = tierOf(b)
|
|
if (ta !== tb) return ta - tb
|
|
return b.ageMs - a.ageMs
|
|
})
|
|
})
|
|
|
|
// 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 panel
|
|
// doesn't go inexplicably empty.
|
|
watch(filterOptions, opts => {
|
|
if (!opts.some(o => o.key === activeFilter.value)) {
|
|
activeFilter.value = 'all'
|
|
}
|
|
})
|
|
|
|
function cardClass(run: LiveRunCard) {
|
|
return {
|
|
'is-stuck': !!run.stuckReason,
|
|
'is-orphan': run.orphan && !run.stuckReason,
|
|
'is-healthy': !run.stuckReason && !run.orphan,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* adds visual noise without informing the user.
|
|
*/
|
|
function showBar(run: LiveRunCard): boolean {
|
|
return run.ageMs > 30_000
|
|
}
|
|
|
|
function progressFillStyle(run: LiveRunCard) {
|
|
// Map age to 0..100% over the 5-minute window. Beyond that we just stay full.
|
|
const pct = Math.min(100, ((run.ageMs - 30_000) / 270_000) * 100)
|
|
return { width: `${Math.max(4, pct)}%` }
|
|
}
|
|
|
|
function childrenOf(run: LiveRunCard): LiveSubagentCard[] {
|
|
// Group by the root conversation so the whole delegation tree shows under its
|
|
// run — including grandchildren, whose immediate parent is a child
|
|
// conversation, not this run. Fall back to parentConversationId for records
|
|
// emitted before rootConversationId existed.
|
|
const subs = (snapshot.value?.subagents ?? [])
|
|
.filter(s => (s.rootConversationId ?? s.parentConversationId) === run.conversationId)
|
|
|
|
// Pre-order DFS by parentSubagentId so each child renders directly above its
|
|
// own descendants, not after every same-depth sibling. A plain depth sort
|
|
// attaches a grandchild under the wrong sibling once a run has >1 branch.
|
|
const byParent = new Map<string | null, LiveSubagentCard[]>()
|
|
for (const s of subs) {
|
|
const key = s.parentSubagentId ?? null
|
|
const bucket = byParent.get(key)
|
|
if (bucket) bucket.push(s)
|
|
else byParent.set(key, [s])
|
|
}
|
|
|
|
const ordered: LiveSubagentCard[] = []
|
|
const seen = new Set<string>()
|
|
const walk = (parentId: string | null) => {
|
|
for (const node of byParent.get(parentId) ?? []) {
|
|
if (seen.has(node.subagentId)) continue // guard against cycles / duplicate ids
|
|
seen.add(node.subagentId)
|
|
ordered.push(node)
|
|
walk(node.subagentId)
|
|
}
|
|
}
|
|
walk(null) // depth-1 children (parentSubagentId null) are the subtree roots
|
|
|
|
// Append any orphan whose parent isn't in this set so it never disappears.
|
|
for (const s of subs) if (!seen.has(s.subagentId)) ordered.push(s)
|
|
return ordered
|
|
}
|
|
|
|
function openDetail(run: LiveRunCard) {
|
|
// Clicking the same card while the panel is open closes it — toggle.
|
|
// Otherwise swap content and (re)open. The focus panel handles its own
|
|
// enter/leave animations, so all we manage here is the open boolean and
|
|
// 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 liveApi.snapshot()
|
|
snapshot.value = (res?.data ?? res) as LiveSnapshot
|
|
if (detail.value && snapshot.value) {
|
|
const fresh = snapshot.value.runs.find(r => r.conversationId === detail.value!.conversationId)
|
|
if (fresh) detail.value = fresh
|
|
}
|
|
// Keep the board's goal columns fresh on the same cadence as the snapshot.
|
|
if (layout.value === 'board') loadGoals()
|
|
} catch (e: any) {
|
|
if (isInitialLoading.value) mcToast.error(e?.message || t('live.errors.loadFailed'))
|
|
} finally {
|
|
isInitialLoading.value = false
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
autoRefresh.value = !autoRefresh.value
|
|
if (autoRefresh.value) {
|
|
refresh()
|
|
timer = setInterval(refresh, 5000)
|
|
} else if (timer) {
|
|
clearInterval(timer)
|
|
timer = null
|
|
}
|
|
}
|
|
|
|
async function confirmStop(run: LiveRunCard) {
|
|
const ok = await mcConfirm({
|
|
title: t('live.confirm.stopTitle'),
|
|
message: t('live.confirm.stopBody', { name: run.agentName || t('live.unknownAgent') }),
|
|
confirmText: t('live.actions.stop'),
|
|
cancelText: t('common.cancel'),
|
|
tone: 'primary',
|
|
})
|
|
if (!ok) return
|
|
try {
|
|
await liveApi.stop(run.conversationId)
|
|
mcToast.success(t('live.toast.stopped'))
|
|
refresh()
|
|
} catch (e: any) {
|
|
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
|
}
|
|
}
|
|
|
|
async function confirmRecycle(run: LiveRunCard) {
|
|
const ok = await mcConfirm({
|
|
title: t('live.confirm.endTitle', { name: run.agentName || t('live.unknownAgent') }),
|
|
message: t('live.confirm.endBody', { name: run.agentName || t('live.unknownAgent') }),
|
|
confirmText: t('live.actions.endIt'),
|
|
cancelText: t('common.cancel'),
|
|
tone: 'danger',
|
|
})
|
|
if (!ok) return
|
|
try {
|
|
await liveApi.recycle(run.conversationId)
|
|
mcToast.success(t('live.toast.ended'))
|
|
drawerOpen.value = false
|
|
refresh()
|
|
} catch (e: any) {
|
|
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
|
}
|
|
}
|
|
|
|
async function confirmInterruptSub(sub: LiveSubagentCard) {
|
|
const ok = await mcConfirm({
|
|
title: t('live.confirm.subTitle'),
|
|
message: t('live.confirm.subBody', { name: sub.agentName || sub.subagentId }),
|
|
confirmText: t('live.actions.stop'),
|
|
cancelText: t('common.cancel'),
|
|
tone: 'primary',
|
|
})
|
|
if (!ok) return
|
|
try {
|
|
await liveApi.interruptSubagent(sub.subagentId)
|
|
mcToast.success(t('live.toast.subStopped'))
|
|
refresh()
|
|
} catch (e: any) {
|
|
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
|
}
|
|
}
|
|
|
|
async function confirmSweep() {
|
|
const stuckCount = snapshot.value?.summary.stuck ?? 0
|
|
const ok = await mcConfirm({
|
|
title: t('live.confirm.sweepTitle'),
|
|
message: t('live.confirm.sweepBody', { n: stuckCount }),
|
|
confirmText: t('live.actions.tidyUp'),
|
|
cancelText: t('common.cancel'),
|
|
tone: 'danger',
|
|
})
|
|
if (!ok) return
|
|
try {
|
|
const res: any = await liveApi.sweep()
|
|
const recycled = res?.data?.recycled ?? 0
|
|
mcToast.success(t('live.toast.swept', { n: recycled }))
|
|
refresh()
|
|
} catch (e: any) {
|
|
mcToast.error(e?.message || t('live.errors.loadFailed'))
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
refresh()
|
|
timer = setInterval(refresh, 5000)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (timer) clearInterval(timer)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.live-panel {
|
|
--card-radius: 24px;
|
|
}
|
|
|
|
/* ===== Toolbar: live toggle + status filters + sweep ===== */
|
|
.live-toolbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.toolbar-spacer {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* ===== Grid / board layout toggle ===== */
|
|
.layout-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 2px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border-light);
|
|
background: var(--mc-bg-muted);
|
|
}
|
|
|
|
.layout-seg {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 12px;
|
|
border-radius: 999px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--mc-text-tertiary);
|
|
font-size: 12.5px;
|
|
font-weight: 500;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
transition: background 0.18s ease, color 0.18s ease;
|
|
}
|
|
|
|
.layout-seg:hover {
|
|
color: var(--mc-text-primary);
|
|
}
|
|
|
|
.layout-seg.is-active {
|
|
background: var(--mc-bg-elevated);
|
|
color: var(--mc-text-primary);
|
|
box-shadow: var(--mc-shadow-soft);
|
|
}
|
|
|
|
/* ===== Filter chip row (kanban-inspired, soft) ===== */
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.filter-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 12px 6px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border-light);
|
|
background: transparent;
|
|
color: var(--mc-text-secondary);
|
|
font-size: 12.5px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
|
font-family: inherit;
|
|
letter-spacing: -0.005em;
|
|
}
|
|
|
|
.filter-chip:hover {
|
|
background: var(--mc-bg-muted);
|
|
color: var(--mc-text-primary);
|
|
border-color: var(--mc-border);
|
|
}
|
|
|
|
.filter-chip-count {
|
|
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
|
|
font-size: 10.5px;
|
|
font-variant-numeric: tabular-nums;
|
|
padding: 1px 7px;
|
|
border-radius: 999px;
|
|
background: var(--mc-bg-muted);
|
|
color: var(--mc-text-tertiary);
|
|
letter-spacing: 0.02em;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
.filter-chip:hover .filter-chip-count {
|
|
background: var(--mc-bg-sunken);
|
|
}
|
|
|
|
.filter-chip.is-active {
|
|
background: var(--mc-text-primary);
|
|
color: var(--mc-bg-elevated);
|
|
border-color: var(--mc-text-primary);
|
|
}
|
|
|
|
.filter-chip.is-active .filter-chip-count {
|
|
background: rgba(255, 255, 255, 0.16);
|
|
color: var(--mc-bg-elevated);
|
|
}
|
|
|
|
html.dark .filter-chip.is-active {
|
|
/* Inverted-pill on a warm-dark surface looks too harsh as pure white;
|
|
soften with the elevated surface token (which already adapts). */
|
|
background: var(--mc-text-primary);
|
|
color: var(--mc-bg);
|
|
border-color: var(--mc-text-primary);
|
|
}
|
|
|
|
html.dark .filter-chip.is-active .filter-chip-count {
|
|
background: rgba(0, 0, 0, 0.22);
|
|
color: var(--mc-bg);
|
|
}
|
|
|
|
/* Active-filter accent tints — stay readable in both themes */
|
|
.filter-chip.is-active.tone-warn {
|
|
background: hsl(20, 75%, 48%);
|
|
border-color: hsl(20, 75%, 48%);
|
|
color: #fff;
|
|
}
|
|
|
|
html.dark .filter-chip.is-active.tone-warn {
|
|
background: hsl(20, 70%, 52%);
|
|
border-color: hsl(20, 70%, 52%);
|
|
color: #fff;
|
|
}
|
|
|
|
.filter-chip.is-active.tone-good {
|
|
background: hsl(155, 45%, 38%);
|
|
border-color: hsl(155, 45%, 38%);
|
|
color: #fff;
|
|
}
|
|
|
|
html.dark .filter-chip.is-active.tone-good {
|
|
background: hsl(155, 45%, 46%);
|
|
border-color: hsl(155, 45%, 46%);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ===== Header chips ===== */
|
|
.chip-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
padding: 7px 14px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border-light);
|
|
background: rgba(255, 255, 255, 0.6);
|
|
color: var(--mc-text-secondary);
|
|
font-size: 12.5px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.18s ease;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
html.dark .chip-btn {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
}
|
|
|
|
.chip-btn:hover {
|
|
background: var(--mc-surface-overlay);
|
|
color: var(--mc-text-primary);
|
|
border-color: var(--mc-border);
|
|
}
|
|
|
|
.chip-btn.is-paused {
|
|
color: var(--mc-text-tertiary);
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.chip-pulse {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: hsl(140, 55%, 50%);
|
|
animation: chip-pulse 2.4s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes chip-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 hsla(140, 55%, 50%, 0.5); }
|
|
50% { box-shadow: 0 0 0 5px hsla(140, 55%, 50%, 0); }
|
|
}
|
|
|
|
.chip-btn-warm {
|
|
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.14), hsla(20, 90%, 55%, 0.2));
|
|
color: hsl(20, 70%, 40%);
|
|
border-color: hsla(20, 80%, 55%, 0.3);
|
|
}
|
|
|
|
.chip-btn-warm:hover {
|
|
background: linear-gradient(135deg, hsla(28, 90%, 60%, 0.22), hsla(20, 90%, 55%, 0.3));
|
|
color: hsl(20, 75%, 35%);
|
|
}
|
|
|
|
/* ===== Cards grid ===== */
|
|
.cards-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.agent-card {
|
|
padding: 24px;
|
|
border-radius: var(--card-radius);
|
|
cursor: pointer;
|
|
position: relative;
|
|
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1),
|
|
box-shadow 0.28s ease,
|
|
border-color 0.28s ease;
|
|
}
|
|
|
|
.agent-card:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 16px 48px -16px rgba(0, 0, 0, 0.16),
|
|
0 4px 12px -4px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.agent-card.is-stuck {
|
|
border-color: hsla(20, 80%, 55%, 0.45);
|
|
background: linear-gradient(180deg, hsla(28, 100%, 96%, 0.95), hsla(20, 100%, 92%, 0.98));
|
|
}
|
|
|
|
html.dark .agent-card.is-stuck {
|
|
background: linear-gradient(180deg, hsla(20, 35%, 22%, 0.96), hsla(15, 30%, 18%, 0.98));
|
|
}
|
|
|
|
.agent-card.is-orphan {
|
|
border-color: hsla(265, 50%, 60%, 0.28);
|
|
}
|
|
|
|
.agent-card-skeleton {
|
|
cursor: default;
|
|
}
|
|
|
|
/* ===== Top row: avatar (with status ring) + name ===== */
|
|
.agent-card-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
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;
|
|
border-radius: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 700;
|
|
font-size: 19px;
|
|
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__glyph) {
|
|
font-size: 28px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.agent-avatar :deep(.skill-icon__img) {
|
|
width: 60%;
|
|
height: 60%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
.agent-avatar :deep(.skill-icon__svg svg) {
|
|
width: 60%;
|
|
height: 60%;
|
|
}
|
|
|
|
.agent-avatar-letter {
|
|
font-size: 19px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.agent-id {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.agent-name {
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
color: var(--mc-text-primary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
letter-spacing: -0.01em;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.agent-owner {
|
|
font-size: 12px;
|
|
color: var(--mc-text-tertiary);
|
|
margin-top: 3px;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
/*
|
|
* 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); }
|
|
}
|
|
|
|
/* ===== Saying + tool chip ===== */
|
|
.agent-saying-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
min-height: 22px;
|
|
}
|
|
|
|
.agent-saying {
|
|
font-size: 14.5px;
|
|
line-height: 1.55;
|
|
color: var(--mc-text-secondary);
|
|
letter-spacing: -0.005em;
|
|
}
|
|
|
|
.agent-card.is-stuck .agent-saying {
|
|
color: hsl(20, 70%, 35%);
|
|
font-weight: 500;
|
|
}
|
|
|
|
html.dark .agent-card.is-stuck .agent-saying {
|
|
color: hsl(28, 80%, 76%);
|
|
}
|
|
|
|
/*
|
|
* Tool chip — monospace, accent-soft tinted. The tool name was previously
|
|
* baked into the sentence and got truncated on narrow cards; promoting it
|
|
* to its own chip makes it ellipsis-resistant and visually weighty. It also
|
|
* stops competing with the sentence for the same line of attention.
|
|
*/
|
|
.tool-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 9px;
|
|
border-radius: 6px;
|
|
background: var(--mc-accent-soft);
|
|
color: var(--mc-accent);
|
|
font-family: ui-monospace, SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, monospace;
|
|
font-size: 11.5px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.01em;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
border: 1px solid transparent;
|
|
}
|
|
|
|
html.dark .tool-chip {
|
|
background: rgba(92, 166, 157, 0.14);
|
|
color: hsl(170, 35%, 70%);
|
|
border-color: rgba(92, 166, 157, 0.18);
|
|
}
|
|
|
|
.agent-card.is-stuck .tool-chip {
|
|
background: hsla(20, 100%, 90%, 0.65);
|
|
color: hsl(20, 75%, 38%);
|
|
border-color: hsla(20, 80%, 55%, 0.25);
|
|
}
|
|
|
|
html.dark .agent-card.is-stuck .tool-chip {
|
|
background: hsla(20, 60%, 30%, 0.4);
|
|
color: hsl(28, 80%, 78%);
|
|
border-color: hsla(20, 70%, 55%, 0.3);
|
|
}
|
|
|
|
/* ===== Meta + bar ===== */
|
|
.agent-meta-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--mc-text-tertiary);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.meta-time {
|
|
font-variant-numeric: tabular-nums;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.meta-pill {
|
|
padding: 2px 9px;
|
|
border-radius: 999px;
|
|
background: var(--mc-bg-muted);
|
|
font-size: 11px;
|
|
letter-spacing: 0.01em;
|
|
border: 1px solid var(--mc-border-light);
|
|
}
|
|
|
|
.meta-pill-orphan {
|
|
background: hsla(265, 50%, 60%, 0.1);
|
|
color: hsl(265, 45%, 50%);
|
|
border-color: hsla(265, 50%, 60%, 0.2);
|
|
}
|
|
|
|
.agent-bar {
|
|
height: 2px;
|
|
border-radius: 1px;
|
|
background: var(--mc-bg-muted);
|
|
overflow: hidden;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, hsl(155, 55%, 65%), hsl(170, 50%, 55%));
|
|
border-radius: 1px;
|
|
transition: width 0.6s ease;
|
|
}
|
|
|
|
.is-stuck .bar-fill {
|
|
background: linear-gradient(90deg, hsl(28, 80%, 60%), hsl(20, 80%, 55%));
|
|
}
|
|
|
|
/* ===== Foot: subagent stack (left) + actions (right) ===== */
|
|
.agent-card-foot {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.card-foot-actions {
|
|
margin-left: auto;
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/*
|
|
* Subagent stack — overlapping circular avatars, like an assignee column.
|
|
* Up to 3 visible; the rest collapse into a "+N" chip. Subagents are good
|
|
* news, they deserve faces, not a count.
|
|
*/
|
|
.subagent-stack {
|
|
display: inline-flex;
|
|
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;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border-light);
|
|
background: transparent;
|
|
color: var(--mc-text-secondary);
|
|
cursor: pointer;
|
|
transition: all 0.18s ease;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.card-action:hover {
|
|
background: var(--mc-bg-muted);
|
|
color: var(--mc-text-primary);
|
|
border-color: var(--mc-border);
|
|
}
|
|
|
|
.card-action-strong {
|
|
border-color: hsla(20, 80%, 55%, 0.5);
|
|
color: hsl(20, 75%, 45%);
|
|
background: hsla(20, 100%, 95%, 0.5);
|
|
}
|
|
|
|
html.dark .card-action-strong {
|
|
background: hsla(20, 80%, 30%, 0.15);
|
|
}
|
|
|
|
.card-action-strong:hover {
|
|
background: hsla(20, 80%, 55%, 0.12);
|
|
color: hsl(20, 75%, 40%);
|
|
}
|
|
|
|
/* ===== Empty state ===== */
|
|
.empty-still {
|
|
text-align: center;
|
|
padding: 90px 20px;
|
|
}
|
|
|
|
.empty-orb {
|
|
width: 72px;
|
|
height: 72px;
|
|
margin: 0 auto 24px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 35% 35%, hsla(155, 55%, 70%, 0.55), hsla(155, 55%, 50%, 0.18));
|
|
animation: breathe-healthy 4s ease-in-out infinite;
|
|
}
|
|
|
|
.empty-line {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--mc-text-primary);
|
|
margin-bottom: 8px;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.empty-hint {
|
|
font-size: 13px;
|
|
color: var(--mc-text-tertiary);
|
|
}
|
|
</style>
|