Files
agent-session-manager/frontend/src/views/SessionList.vue
T

797 lines
40 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 class="filter-group">
<button class="filter-btn" :class="{ active: favoriteOnly }" @click="onFavoriteOnlyChange()" title="只看收藏会话"
aria-label="只看收藏会话" :aria-pressed="String(favoriteOnly)"> 只看收藏</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>{{ favoriteOnly ? '暂无收藏的会话。' : '无符合当前引擎或状态过滤条件的会话实例。' }}</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>
</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-side">
<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 class="card-actions">
<button class="action-btn" @click.stop="openNameEditor(s)" title="编辑名称" aria-label="编辑自定义名称">
<span class="icon">✏️</span><span class="text">编辑</span>
</button>
<button
class="action-btn fav"
:class="{ 'is-fav': s.is_favorite }"
:disabled="isFavoritePending(s)"
@click.stop="toggleFavorite(s)"
:title="s.is_favorite ? '取消收藏' : '收藏'"
:aria-label="s.is_favorite ? '取消收藏此会话' : '收藏此会话'"
:aria-pressed="String(s.is_favorite)"
>
<span class="icon">{{ s.is_favorite ? '★' : '☆' }}</span>
<span class="text">{{ isFavoritePending(s) ? '处理中' : (s.is_favorite ? '已收藏' : '收藏') }}</span>
</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>
<button
class="fav-btn-drawer"
:class="{ 'is-fav': drawerSession.is_favorite }"
:disabled="isFavoritePending(drawerSession)"
@click.stop="toggleFavorite(drawerSession)"
:title="drawerSession.is_favorite ? '取消收藏' : '收藏'"
:aria-label="drawerSession.is_favorite ? '取消收藏此会话' : '收藏此会话'"
:aria-pressed="String(drawerSession.is_favorite)"
>{{ drawerSession.is_favorite ? '★' : '☆' }} {{ drawerSession.is_favorite ? '取消收藏' : '收藏' }}</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>
<button v-if="drawerSession.can_delete" class="footer-btn-delete" @click="showDeleteConfirm(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>
<!-- Delete Confirmation Dialog -->
<div v-if="deleteConfirmVisible" class="dialog-overlay" @click.self="deleteConfirmVisible = false">
<div class="dialog">
<div class="dialog-head">
<h3>确认删除会话</h3>
<button class="dialog-close" @click="deleteConfirmVisible = false"></button>
</div>
<div class="dialog-body">
<p class="dialog-hint">此操作将从 OpenCode 数据库中永久删除该会话不可恢复</p>
<p class="dialog-id mono">{{ deleteTarget?.id }}</p>
</div>
<div class="dialog-foot">
<button class="dialog-btn" @click="deleteConfirmVisible = false" :disabled="deleting">取消</button>
<button class="dialog-btn danger" @click="confirmDelete" :disabled="deleting">{{ deleting ? '删除中...' : '确认删除' }}</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, deleteSession, favoriteSession, unfavoriteSession } 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 favoriteOnly = ref(false)
const favoritePendingMap = ref({})
function isFavoritePending(s) {
return !!favoritePendingMap.value[`${s.engine}:${s.id}`]
}
function setFavoritePending(s, val) {
if (val) {
favoritePendingMap.value[`${s.engine}:${s.id}`] = true
} else {
delete favoritePendingMap.value[`${s.engine}:${s.id}`]
}
}
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,
favorite: favoriteOnly.value ? '1' : undefined,
})
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)
}
}
const deleteConfirmVisible = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const refreshDashboard = inject('refreshDashboard', () => {})
function showDeleteConfirm(session) {
deleteTarget.value = session
deleteConfirmVisible.value = true
}
async function confirmDelete() {
const session = deleteTarget.value
if (!session || deleting.value) return
deleting.value = true
try {
await deleteSession(session.engine, session.id)
ElMessage.success('会话已删除')
deleteConfirmVisible.value = false
deleteTarget.value = null
closeDrawer()
loadSessions()
await refreshDashboard()
} catch (err) {
ElMessage.error('删除失败: ' + (err.message || ''))
} finally {
deleting.value = false
}
}
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
}
}
function toggleFavorite(s) {
if (!s || isFavoritePending(s)) return
setFavoritePending(s, true)
const action = s.is_favorite ? unfavoriteSession : favoriteSession
action(s.engine, s.id)
.then((result) => {
// Use API response as immediate source of truth
s.is_favorite = result.is_favorite
s.favorited_at = result.favorited_at
// Sync drawer immediately if it matches the acted-on session
if (drawerSession.value && drawerSession.value.id === s.id && drawerSession.value.engine === s.engine) {
drawerSession.value.is_favorite = result.is_favorite
drawerSession.value.favorited_at = result.favorited_at
}
if (favoriteOnly.value && !result.is_favorite) {
const idx = sessions.value.findIndex(x => x.id === s.id && x.engine === s.engine)
if (idx !== -1) {
sessions.value.splice(idx, 1)
total.value = Math.max(0, total.value - 1)
if (displayedSessions.value.length === 0 && page.value > 1) {
page.value--
return loadSessions()
}
}
}
})
.catch((err) => {
ElMessage.error('收藏操作失败: ' + (err.message || ''))
})
.finally(() => {
setFavoritePending(s, false)
})
}
function onFavoriteOnlyChange() {
favoriteOnly.value = !favoriteOnly.value
}
watch(() => props.selectedEngine, () => { page.value = 1; loadSessions() })
watch(favoriteOnly, () => { 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; }
.filter-group + .filter-group .filter-btn { min-height: 44px; }
/* 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: 1rem; font-weight: 700; 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-side { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
.card-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; }
.action-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-width: 44px; min-height: 44px; padding: 4px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; background: transparent; transition: all 0.12s; border: 1px solid var(--border); color: var(--text-secondary); }
.action-btn:hover { border-color: var(--accent); color: var(--accent-light); }
.action-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.action-btn.fav { border-color: var(--border); }
.action-btn.fav:hover { border-color: #f59e0b; color: #f59e0b; }
.action-btn.fav.is-fav { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.1); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; border-color: var(--border) !important; color: var(--text-muted) !important; background: transparent !important; }
.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; min-height: 44px; min-width: 44px; }
.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; min-height: 44px; min-width: 44px; }
.dialog-btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.dialog-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.dialog-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.dialog-id { font-size: 11px; color: var(--text-muted); font-family: 'SF Mono', monospace; word-break: break-all; }
.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-main { padding-right: 0; width: 100%; }
.card-tags { gap: 4px; }
.id-preview { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.card-side { flex-direction: column; align-items: flex-end; width: 100%; gap: 12px; }
.card-meta { width: 100%; justify-content: space-around; }
.card-actions { width: 100%; justify-content: flex-end; }
.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;
min-height: 44px;
min-width: 44px;
}
.footer-btn-detail:hover { background: var(--accent); color: #fff; }
.footer-btn-delete {
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 rgba(239,68,68,0.3); background: rgba(239,68,68,0.08);
color: var(--danger); transition: all 0.12s;
min-height: 44px;
min-width: 44px;
}
.footer-btn-delete:hover { background: var(--danger); color: #fff; }
@media (max-width: 480px) {
.drawer-footer { flex-wrap: wrap; }
.footer-btn-copy, .footer-btn-uuid, .footer-btn-detail { flex: 1 1 100%; }
}
/* Favorite Buttons */
.fav-btn-card {
background: none; border: none; font-size: 14px; cursor: pointer;
padding: 0; transition: transform 0.12s; line-height: 1;
color: var(--text-muted);
min-width: 44px; min-height: 44px;
display: inline-flex; align-items: center; justify-content: center;
}
.fav-btn-card:hover { transform: scale(1.2); }
.fav-btn-card.is-fav { color: #f59e0b; }
.fav-btn-card:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.fav-btn-drawer {
background: none; border: 1px solid var(--border); padding: 2px 8px;
border-radius: 4px; font-size: 10px; color: var(--text-secondary); cursor: pointer;
transition: all 0.12s;
min-width: 44px; min-height: 44px;
display: inline-flex; align-items: center; justify-content: center;
}
.fav-btn-drawer:hover { border-color: #f59e0b; color: #f59e0b; }
.fav-btn-drawer.is-fav { border-color: #f59e0b; color: #f59e0b; background: rgba(245,158,11,0.1); }
.fav-btn-drawer:disabled { opacity: 0.4; cursor: not-allowed; }
</style>