Initial commit

This commit is contained in:
liujing
2026-07-16 15:42:50 +08:00
commit 535b03712e
33 changed files with 7893 additions and 0 deletions
+607
View File
@@ -0,0 +1,607 @@
<template>
<div class="sessions-view">
<!-- Stats Cards -->
<div class="stats-row">
<div class="stat-card">
<p class="stat-lbl">活跃会话集群</p>
<h3 class="stat-val">{{ activeCount }}/{{ total }}</h3>
</div>
<div class="stat-card">
<p class="stat-lbl">当前筛选调用量</p>
<h3 class="stat-val accent">{{ filteredCalls.toLocaleString() }} <span class="stat-unit"></span></h3>
</div>
<div class="stat-card">
<p class="stat-lbl">引擎分布情况</p>
<div class="engine-dots">
<span v-for="(c, eng) in engineDist" :key="eng" class="engine-dot" :style="{ background: getEngineMeta(eng).color }" :title="`${getEngineMeta(eng).label}: ${c}`">{{ getEngineMeta(eng).label }}({{ c }})</span>
</div>
</div>
<div class="stat-card">
<p class="stat-lbl">异常/中断率</p>
<h3 class="stat-val success">{{ errorRate }}%</h3>
</div>
</div>
<!-- Search + Filter -->
<div class="search-bar">
<div class="search-wrap">
<span class="search-ico">🔍</span>
<input v-model="query" class="search-input" placeholder="输入摘要、会话 ID、文件路径关键字检索..." @keyup.enter="doSearch" />
<button v-if="query" class="search-clear" @click="query = ''; doSearch()"></button>
</div>
<div class="filter-group">
<span class="filter-lbl">状态过滤:</span>
<button v-for="opt in statusOpts" :key="opt.key" class="filter-btn" :class="{ active: statusFilter === opt.key }" @click="statusFilter = opt.key; doSearch()">{{ opt.label }}</button>
</div>
</div>
<!-- Session Cards -->
<div class="session-list">
<div class="list-header" v-if="sessions.length">
<span class="list-header-text">已过滤出 {{ displayedSessions.length }} 个智能体会话监控流 (Engine: {{ selectedEngine.toUpperCase() }})</span>
</div>
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="!displayedSessions.length" class="empty-state">
<p>无符合当前引擎或状态过滤条件的会话实例</p>
</div>
<div v-else class="card-grid">
<div
v-for="s in displayedSessions"
:key="s.key || s.id"
class="session-card"
:style="{ borderLeftColor: getEngineMeta(s.engine).color }"
@click="openDrawer(s)"
>
<div class="card-main">
<div class="card-tags">
<span class="engine-badge" :style="{ background: getEngineMeta(s.engine).bg, color: getEngineMeta(s.engine).color, borderColor: getEngineMeta(s.engine).border }">
{{ s.engine_label || getEngineMeta(s.engine).label }}
</span>
<span class="status-badge" :class="'status-' + (s.status || 'unknown')">
<span class="status-dot-card" :class="'dot-' + (s.status || 'unknown')"></span>
{{ statusLabel(s.status) }}
</span>
<span class="id-preview mono">会话 ID: <span class="id-highlight">{{ (s.id || '').substring(0, 15) }}...</span></span>
<button class="name-btn-card" @click.stop="openNameEditor(s)" title="自定义名称"></button>
</div>
<h4 class="card-summary">{{ s.custom_name || s.summary || '(无消息)' }}</h4>
<div v-if="s.custom_name" class="card-original-summary">{{ s.summary }}</div>
<div class="card-path">
<span class="path-ico">📁</span>
<span class="path-text">{{ s.cwd || '-' }}</span>
</div>
</div>
<div class="card-meta">
<div class="meta-col">
<span class="meta-col-label">对应版本</span>
<span class="meta-col-val">{{ s.cli_version || s.model || '-' }}</span>
</div>
<div class="meta-col">
<span class="meta-col-label">工具调用</span>
<span class="meta-col-val calls" :class="callsClass(s.tool_call_count)">{{ s.tool_call_count || 0 }} </span>
</div>
<div class="meta-col">
<span class="meta-col-label">开始时间</span>
<span class="meta-col-val time">{{ formatTimestamp(s.created_at) }}</span>
</div>
<div class="meta-col">
<button class="detail-btn" @click.stop="goToDetailPage(s)" title="进入详情页">详情 </button>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="total > pageSize">
<span class="page-info"> {{ total }} </span>
<div class="page-btns">
<button class="page-btn" :disabled="page <= 1" @click="goPage(page - 1)"></button>
<button v-for="p in pageNums" :key="p" v-if="p !== '...'" class="page-btn" :class="{ active: p === page }" @click="goPage(p)">{{ p }}</button>
<span v-else class="page-ellipsis">...</span>
<button class="page-btn" :disabled="page >= maxPage" @click="goPage(page + 1)"></button>
</div>
</div>
<!-- Detail Drawer -->
<div v-if="drawerSession" class="drawer-overlay" @click.self="closeDrawer">
<div class="drawer">
<div class="drawer-head">
<div class="drawer-title-bar">
<span class="drawer-badge" :style="{ background: getEngineMeta(drawerSession.engine).bg, color: getEngineMeta(drawerSession.engine).color, borderColor: getEngineMeta(drawerSession.engine).border }">
{{ getEngineMeta(drawerSession.engine).label }} · 运行诊断
</span>
<button class="drawer-close" @click="closeDrawer"></button>
</div>
<h3 class="drawer-summary">{{ drawerSession.custom_name || drawerSession.summary || '(无消息)' }}</h3>
<div v-if="drawerSession.custom_name" class="drawer-original-summary">{{ drawerSession.summary }}</div>
<div class="drawer-name-row">
<p class="drawer-id mono">完整会话 UUID: {{ drawerSession.id }}</p>
<button class="name-edit-btn" @click.stop="openNameEditor(drawerSession)"> 命名</button>
<button class="name-edit-btn" @click.stop="generateDrawerTitle" :disabled="drawerAiLoading">🤖 {{ drawerAiLoading ? '生成中...' : 'AI 生成' }}</button>
<button v-if="drawerSession.custom_name" class="name-edit-btn" @click.stop="clearCustomName(drawerSession)">🗑 清除</button>
</div>
</div>
<div class="drawer-body" v-if="drawerDetail">
<!-- detail content unchanged -->
<div class="drawer-meta-box">
<div class="meta-row"><span class="meta-lbl">宿主执行用户:</span><span class="meta-val">{{ drawerSession.model || '-' }}</span></div>
<div class="meta-row"><span class="meta-lbl">引擎运行时版本:</span><span class="meta-val">{{ drawerSession.cli_version || '-' }}</span></div>
<div class="meta-row"><span class="meta-lbl">工作物理路径:</span><span class="meta-val accent mono">{{ drawerSession.cwd || '-' }}</span></div>
<div class="meta-row"><span class="meta-lbl">运行总时长:</span><span class="meta-val">{{ drawerSession.duration_ms ? Math.round(drawerSession.duration_ms / 60000) + ' 分钟' : '暂无数据' }}</span></div>
<div class="meta-row"><span class="meta-lbl">诊断执行状态:</span><span class="meta-val">
<span class="status-tag-drawer" :class="'tag-' + (drawerSession.status || 'unknown')">{{ statusLabel(drawerSession.status) }}</span>
</span></div>
</div>
<div class="drawer-section">
<h4 class="drawer-section-title">交互式智能体思考环与工具调用 Trace 链路</h4>
<div v-if="!drawerDetail.messages?.length && drawerSession?.engine === 'agy' && drawerSession?.has_detail_db === false" class="trace-empty">该会话的详细 Trace 数据存储在 per-conversation protobuf 本地详情缓存不可用</div>
<div v-else-if="!drawerDetail.messages?.length" class="trace-empty">该历史会话没有持久化的 Trace 调试详情数据</div>
<div v-else class="trace-list">
<div v-for="(msg, i) in drawerDetail.messages.slice(0, 30)" :key="i" class="trace-item">
<span class="trace-dot" :class="traceDot(msg.type)"></span>
<div class="trace-content">
<span class="trace-tag" :class="'trace-tag-' + traceTag(msg.type)">{{ traceLabel(msg.type) }}</span>
<span class="trace-msg">{{ msg.message || msg.name || msg.output_preview || '' }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="drawerError" class="drawer-body">
<div class="drawer-error-box">
<p class="drawer-error-text">{{ drawerError }}</p>
<button class="drawer-retry-btn" @click="retryDrawer">重试</button>
</div>
</div>
<div v-else-if="!drawerDetail && !drawerError" class="drawer-body">
<div class="loading-state">加载中...</div>
</div>
<div class="drawer-footer">
<button class="footer-btn-copy" @click="copyResume(drawerSession)">
📋 复制恢复命令
</button>
<button class="footer-btn-uuid" @click="copyUUID(drawerSession.id)">
📋 复制 UUID 句柄
</button>
<button class="footer-btn-detail" @click="goToDetailPage(drawerSession)">
进入详情页
</button>
</div>
</div>
</div>
<!-- Name Editor Dialog -->
<div v-if="editNameVisible" class="dialog-overlay" @click.self="editNameVisible = false">
<div class="dialog">
<div class="dialog-head">
<h3>自定义名称</h3>
<button class="dialog-close" @click="editNameVisible = false"></button>
</div>
<div class="dialog-body">
<p class="dialog-hint">为本会话设置一个易记的名称仅在本地使用</p>
<input
v-model="editNameValue"
class="dialog-input"
placeholder="输入自定义名称..."
maxlength="100"
@keyup.enter="saveCustomName"
/>
<p class="dialog-counter">{{ editNameValue.length }}/100</p>
</div>
<div class="dialog-foot">
<button class="dialog-btn" @click="editNameVisible = false">取消</button>
<button class="dialog-btn primary" @click="saveCustomName">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, inject, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getSessions, getSessionDetail, getGlobalTools, formatTimestamp, getEngineMeta, ENGINE_META, setCustomName, deleteCustomName, generateTitle } from '../api/index.js'
import { useRouter } from 'vue-router'
const props = defineProps({ selectedEngine: { type: String, default: 'all' } })
const sessions = ref([])
const total = ref(0)
const loading = ref(false)
const query = ref('')
const statusFilter = ref('all')
const page = ref(1)
const pageSize = 50
const drawerSession = ref(null)
const drawerDetail = ref(null)
const drawerError = ref(null)
const drawerAiLoading = ref(false)
const router = useRouter()
const activeSessions = ref(0)
const filteredCalls = ref(0)
const statusOpts = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '执行中' },
{ key: 'success', label: '已完成' },
{ key: 'failed', label: '已中断' },
]
const engineDist = computed(() => {
const dist = {}
for (const s of sessions.value) {
dist[s.engine] = (dist[s.engine] || 0) + 1
}
return dist
})
const errorCount = computed(() => sessions.value.filter(s => s.status === 'error' || s.status === 'interrupted').length)
const errorRate = computed(() => sessions.value.length ? Math.round(errorCount.value / sessions.value.length * 100) : 0)
const activeCount = computed(() => sessions.value.filter(s => s.status === 'active').length)
const displayedSessions = computed(() => {
let list = sessions.value
if (statusFilter.value === 'success') list = list.filter(s => s.status === 'success')
else if (statusFilter.value === 'failed') list = list.filter(s => s.status === 'error' || s.status === 'interrupted')
else if (statusFilter.value === 'active') list = list.filter(s => s.status === 'active')
return list
})
const maxPage = computed(() => Math.ceil(total.value / pageSize))
const pageNums = computed(() => {
const p = [], tp = maxPage.value
if (tp <= 7) { for (let i = 1; i <= tp; i++) p.push(i) }
else {
p.push(1)
if (page.value > 3) p.push('...')
for (let i = Math.max(2, page.value - 1); i <= Math.min(tp - 1, page.value + 1); i++) p.push(i)
if (page.value < tp - 2) p.push('...')
p.push(tp)
}
return p
})
function statusLabel(status) {
return { success: '已成功', error: '已中断', interrupted: '已中断', active: '执行中', unknown: '未知' }[status] || status
}
function callsClass(c) {
if (c > 100) return 'calls-danger'
if (c > 50) return 'calls-warn'
return 'calls-normal'
}
function traceTag(type) {
const map = { user_message: 'primary', function_call: 'warning', function_call_output: 'info', assistant_message: 'success' }
return map[type] || ''
}
function traceDot(type) {
const map = { user_message: 'dot-primary', function_call: 'dot-warning', function_call_output: 'dot-info', assistant_message: 'dot-success' }
return map[type] || 'dot-default'
}
function traceLabel(type) {
const map = { user_message: '用户', function_call: '工具', function_call_output: '输出', assistant_message: 'AI' }
return map[type] || type
}
async function loadSessions() {
loading.value = true
try {
// Map frontend status values to backend status values
let apiStatus = statusFilter.value;
if (apiStatus === 'all') apiStatus = null;
// 'failed' is handled by backend as error+interrupted
const data = await getSessions({
engine: props.selectedEngine,
query: query.value,
status: apiStatus,
limit: pageSize,
offset: (page.value - 1) * pageSize,
})
sessions.value = data.sessions || []
total.value = data.total || 0
// Also fetch stats for calls
const tools = await getGlobalTools({ engine: props.selectedEngine })
filteredCalls.value = tools.tool_usage?.reduce((a, b) => a + b.total_calls, 0) || 0
} catch (err) {
ElMessage.error('加载失败: ' + err.message)
} finally {
loading.value = false
}
}
function doSearch() { page.value = 1; loadSessions() }
function goPage(p) { if (p >= 1 && p <= maxPage.value) { page.value = p; loadSessions() } }
async function openDrawer(s) {
drawerSession.value = s
drawerDetail.value = null
drawerError.value = null
try {
const data = await getSessionDetail(s.engine, s.id)
drawerDetail.value = data
} catch (err) {
drawerError.value = '加载详情失败: ' + (err.message || '')
}
}
function retryDrawer() {
if (drawerSession.value) openDrawer(drawerSession.value)
}
function closeDrawer() { drawerSession.value = null; drawerDetail.value = null; drawerError.value = null }
function copyResume(s) {
if (!s.resume_command) return
navigator.clipboard.writeText(s.resume_command).then(() => {
ElMessage.success(`已复制: ${s.resume_command}`)
}).catch(() => { ElMessage.success(`手动复制: ${s.resume_command}`) })
}
function copyUUID(id) {
navigator.clipboard.writeText(id).then(() => ElMessage.success('UUID 已复制'))
}
// Custom name editing
const editNameVisible = ref(false)
const editNameValue = ref('')
const editNameTarget = ref(null)
function openNameEditor(session) {
editNameTarget.value = session
editNameValue.value = session.custom_name || ''
editNameVisible.value = true
}
async function saveCustomName() {
const s = editNameTarget.value
if (!s) return
try {
const result = await setCustomName(s.engine, s.id, editNameValue.value)
// Update local state
s.custom_name = result.custom_name || null
// Also update drawer session if same
if (drawerSession.value?.id === s.id && drawerSession.value?.engine === s.engine) {
drawerSession.value.custom_name = s.custom_name
}
editNameVisible.value = false
ElMessage.success('名称已保存')
} catch (err) {
ElMessage.error('保存失败: ' + err.message)
}
}
async function clearCustomName(session) {
try {
await deleteCustomName(session.engine, session.id)
session.custom_name = null
if (drawerSession.value?.id === session.id && drawerSession.value?.engine === session.engine) {
drawerSession.value.custom_name = null
}
ElMessage.success('名称已清除')
} catch (err) {
ElMessage.error('清除失败: ' + err.message)
}
}
function goToDetailPage(session) {
if (!session) return
closeDrawer()
router.push({ name: 'session-detail', params: { id: session.id }, query: { engine: session.engine } })
}
async function generateDrawerTitle() {
const s = drawerSession.value
if (!s) return
drawerAiLoading.value = true
try {
const result = await generateTitle(s.engine, s.id)
if (result.title) {
editNameTarget.value = s
editNameValue.value = result.title
editNameVisible.value = true
ElMessage.success('标题已生成,可点击保存确认')
} else {
ElMessage.warning(result.error || '生成失败')
}
} catch (err) {
ElMessage.error('生成失败: ' + err.message)
} finally {
drawerAiLoading.value = false
}
}
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
onMounted(loadSessions)
</script>
<style scoped>
.sessions-view { width: 100%; }
/* Stats */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; }
.stat-card { padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); }
.stat-lbl { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.stat-val { font-size: 22px; font-weight: 800; font-family: 'SF Mono', monospace; color: var(--text-primary); }
.stat-val.accent { color: var(--accent-light); }
.stat-val.success { color: var(--success); }
.stat-unit { font-size: 13px; font-weight: 400; color: var(--text-muted); }
.engine-dots { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.engine-dot { font-size: 10px; padding: 1px 6px; border-radius: 4px; color: #fff; font-family: 'SF Mono', monospace; }
/* Search */
.search-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 16px; }
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 480px; }
.search-ico { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; opacity: 0.5; }
.search-input { width: 100%; padding: 9px 32px 9px 36px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
.search-input:focus { border-color: var(--accent); }
.search-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; }
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-lbl { font-size: 11px; color: var(--text-muted); }
.filter-btn { padding: 5px 12px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 11px; cursor: pointer; }
.filter-btn:hover { background: var(--accent-bg); }
.filter-btn.active { background: var(--accent-bg); border-color: var(--accent); color: var(--accent-light); font-weight: 600; }
/* Session Cards */
.session-list { }
.list-header { margin-bottom: 12px; }
.list-header-text { font-size: 12px; color: var(--text-muted); font-weight: 500; }
.loading-state { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
.empty-state { padding: 48px; text-align: center; color: var(--text-muted); border-radius: 12px; border: 1px solid var(--border); background: var(--bg-card); font-size: 13px; }
.card-grid { display: flex; flex-direction: column; gap: 12px; }
.session-card {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border);
background: var(--bg-card); cursor: pointer; transition: all 0.12s;
border-left: 4px solid var(--accent);
}
.session-card:hover { border-color: var(--text-muted); background: var(--bg-card-hover); }
.card-main { flex: 1; min-width: 0; padding-right: 16px; }
.card-tags { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.engine-badge { padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; letter-spacing: 0.5px; border: 1px solid; font-family: 'SF Mono', monospace; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 9px; font-weight: 600; }
.status-badge.status-success { background: rgba(16,185,129,0.1); color: var(--success); }
.status-badge.status-error { background: rgba(239,68,68,0.1); color: var(--danger); }
.status-badge.status-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
.status-badge.status-active { background: rgba(59,130,246,0.1); color: #3b82f6; animation: pulse 1.5s infinite; }
.status-badge.status-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
.status-dot-card { width: 6px; height: 6px; border-radius: 50%; }
.dot-success { background: var(--success); }
.dot-error { background: var(--danger); }
.dot-interrupted { background: var(--warning); }
.dot-active { background: #3b82f6; }
.dot-unknown { background: var(--text-muted); }
.id-preview { font-size: 10px; color: var(--text-muted); }
.id-highlight { color: var(--accent-light); }
.name-btn-card { background: none; border: none; font-size: 12px; cursor: pointer; opacity: 0.4; padding: 0 2px; }
.name-btn-card:hover { opacity: 1; }
.card-summary { font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.4; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-original-summary { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
.card-path { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--text-muted); }
.path-ico { font-size: 12px; }
.path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: 'SF Mono', monospace; }
.card-meta { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.meta-col { text-align: center; }
.meta-col-label { display: block; font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
.meta-col-val { font-size: 11px; font-family: 'SF Mono', monospace; color: var(--text-secondary); }
.meta-col-val.time { font-size: 10px; }
.meta-col-val.calls { font-weight: 600; padding: 1px 8px; border-radius: 10px; }
.calls-danger { color: var(--danger); background: rgba(239,68,68,0.1); }
.calls-warn { color: var(--warning); background: rgba(245,158,11,0.1); }
.calls-normal { color: var(--text-secondary); background: rgba(100,116,139,0.1); }
.arrow { font-size: 18px; color: var(--text-muted); }
/* Pagination */
.pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; padding: 12px; }
.page-info { font-size: 12px; color: var(--text-muted); }
.page-btns { display: flex; gap: 4px; align-items: center; }
.page-btn { min-width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); font-size: 12px; font-family: 'SF Mono', monospace; cursor: pointer; }
.page-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.page-btn:disabled { opacity: 0.4; }
.page-ellipsis { padding: 0 4px; color: var(--text-muted); }
/* Drawer */
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 500; display: flex; justify-content: flex-end; }
.drawer { width: 100%; max-width: 560px; height: 100%; background: var(--bg-card); border-left: 1px solid var(--border); display: flex; flex-direction: column; animation: slideIn 0.25s ease; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.drawer-head { padding: 20px; border-bottom: 1px solid var(--border); background: rgba(15,23,42,0.3); }
.drawer-title-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.drawer-badge { padding: 3px 10px; border-radius: 4px; font-size: 10px; font-weight: 700; border: 1px solid; font-family: 'SF Mono', monospace; }
.drawer-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
.drawer-close:hover { color: var(--text-primary); }
.drawer-summary { font-size: 14px; font-weight: 600; color: var(--text-primary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.drawer-id { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
.drawer-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
.drawer-meta-box { padding: 12px 16px; border-radius: 10px; border: 1px solid var(--border); background: rgba(15,23,42,0.2); margin-bottom: 16px; }
.meta-row { display: grid; grid-template-columns: 100px 1fr; gap: 8px; padding: 5px 0; font-size: 11px; }
.meta-lbl { color: var(--text-muted); }
.meta-val { color: var(--text-primary); font-weight: 500; }
.meta-val.accent { color: var(--accent-light); }
.status-tag-drawer { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 9px; font-weight: 700; }
.tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
.tag-error { background: rgba(239,68,68,0.1); color: var(--danger); }
.tag-interrupted { background: rgba(245,158,11,0.1); color: var(--warning); }
.tag-active { background: rgba(59,130,246,0.1); color: #3b82f6; }
.tag-unknown { background: rgba(100,116,139,0.1); color: var(--text-muted); }
.drawer-section { margin-bottom: 16px; }
.drawer-section-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; }
.drawer-error-box { padding: 32px; text-align: center; }
.drawer-error-text { font-size: 13px; color: var(--danger); margin-bottom: 12px; }
.drawer-retry-btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent-bg); color: var(--accent-light); cursor: pointer; font-size: 12px; }
.trace-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 12px; border: 1px dashed var(--border); border-radius: 8px; }
.trace-list { }
.trace-item { display: flex; gap: 8px; padding: 4px 0; align-items: flex-start; }
.trace-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.dot-primary { background: var(--accent); }
.dot-success { background: var(--success); }
.dot-warning { background: var(--warning); }
.dot-info { background: var(--text-muted); }
.dot-default { background: var(--text-muted); }
.trace-content { flex: 1; min-width: 0; }
.trace-tag { display: inline-block; padding: 0 6px; border-radius: 3px; font-size: 9px; font-weight: 600; margin-right: 6px; }
.trace-tag-primary { background: var(--accent-bg); color: var(--accent-light); }
.trace-tag-success { background: rgba(16,185,129,0.1); color: var(--success); }
.trace-tag-warning { background: rgba(245,158,11,0.1); color: var(--warning); }
.trace-tag-info { background: rgba(100,116,139,0.1); color: var(--text-muted); }
.trace-msg { font-size: 10px; color: var(--text-secondary); }
.drawer-footer { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); background: rgba(15,23,42,0.2); }
.footer-btn-copy, .footer-btn-uuid { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; }
.footer-btn-copy { background: var(--accent); border: none; color: #fff; }
.footer-btn-copy:hover { opacity: 0.9; }
.footer-btn-uuid { border: 1px solid var(--border); background: var(--bg-input); color: var(--text-secondary); }
.footer-btn-uuid:hover { background: var(--bg-card-hover); }
/* Name editor */
.drawer-original-summary { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.drawer-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.name-edit-btn { background: none; border: 1px solid var(--border); padding: 2px 8px; border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer; }
.name-edit-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.detail-btn { background: var(--accent-bg); border: 1px solid var(--accent-border); padding: 4px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; color: var(--accent-light); cursor: pointer; white-space: nowrap; }
.detail-btn:hover { background: var(--accent); color: #fff; }
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 600; display: flex; align-items: center; justify-content: center; }
.dialog { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; width: 90%; max-width: 420px; }
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); }
.dialog-head h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); }
.dialog-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
.dialog-body { padding: 16px 20px; }
.dialog-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
.dialog-input { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text-primary); font-size: 13px; outline: none; }
.dialog-input:focus { border-color: var(--accent); }
.dialog-counter { text-align: right; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
.dialog-foot { display: flex; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--border); justify-content: flex-end; }
.dialog-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 12px; cursor: pointer; }
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.dialog-btn:hover { opacity: 0.9; }
@media (max-width: 768px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.session-card { flex-direction: column; align-items: flex-start; gap: 8px; }
.card-meta { width: 100%; justify-content: space-around; }
.drawer { max-width: 100%; }
}
.footer-btn-detail {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 10px; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer;
border: 1px solid var(--accent-border); background: var(--accent-bg);
color: var(--accent-light); transition: all 0.12s;
}
.footer-btn-detail:hover { background: var(--accent); color: #fff; }
@media (max-width: 480px) {
.drawer-footer { flex-wrap: wrap; }
.footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; }
}
</style>