feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'MemoryDetail' });
|
||||
defineProps({ id: String });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-placeholder">MemoryDetail view for {{ id }} (TODO)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,736 @@
|
||||
<script setup>
|
||||
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG, clearUndo } from '../store.js';
|
||||
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative, renderMarkdown } from '../utils.js';
|
||||
import { loadMemoryMarkdown, archiveMemory, restoreMemory } from '../data.js';
|
||||
|
||||
defineOptions({ name: 'MemoryList' });
|
||||
|
||||
const router = useRouter();
|
||||
const listWrapRef = ref(null);
|
||||
const undoCountdown = ref(0);
|
||||
|
||||
// --- Filtered/sorted memories ---
|
||||
|
||||
const visibleMemories = computed(() => {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
return state.memories
|
||||
.filter(m => {
|
||||
if (state.view === 'archived') return m.archived;
|
||||
return !m.archived;
|
||||
})
|
||||
.filter(m => state.projectFilter === 'all' || m.project === state.projectFilter)
|
||||
.filter(m => !q || (m.path || '').toLowerCase().includes(q) || (m.summary || '').toLowerCase().includes(q))
|
||||
.sort((a, b) => state.sortDesc ? b.ts - a.ts : a.ts - b.ts);
|
||||
});
|
||||
|
||||
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||
|
||||
// --- Detail state ---
|
||||
|
||||
const detailMemory = ref(null);
|
||||
const detailMarkdown = ref(null);
|
||||
const showSource = ref(false);
|
||||
const loadingMarkdown = ref(false);
|
||||
|
||||
const showDetail = computed(() => detailMemory.value !== null);
|
||||
|
||||
// --- Row helpers ---
|
||||
|
||||
function dominantRowStatus(m) {
|
||||
if (m.health === 'broken') return 'broken';
|
||||
if (m.health === 'partial') return 'partial';
|
||||
if (m.archived) return 'archived';
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusGlyphs(status) {
|
||||
if (!status) return '';
|
||||
const map = {
|
||||
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
|
||||
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
|
||||
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
|
||||
};
|
||||
return map[status] || '';
|
||||
}
|
||||
|
||||
function pathHTML(m) {
|
||||
return highlightPlain(m.path || '', state.query.trim());
|
||||
}
|
||||
|
||||
function summaryHTML(m) {
|
||||
return highlightPlain(m.summary || '', state.query.trim());
|
||||
}
|
||||
|
||||
function timeLabel(m) {
|
||||
return fmtListTime(m.ts);
|
||||
}
|
||||
|
||||
function projectLabel(m) {
|
||||
return escapeHTML(formatProjectLabel(m.project));
|
||||
}
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
function toggleSelection(id) {
|
||||
const s = new Set(state.selection);
|
||||
if (s.has(id)) s.delete(id);
|
||||
else s.add(id);
|
||||
state.selection = s;
|
||||
}
|
||||
|
||||
// --- Cursor navigation ---
|
||||
|
||||
function moveCursor(direction) {
|
||||
const items = visibleMemories.value;
|
||||
if (!items.length) return;
|
||||
const curIdx = items.findIndex(m => m.id === state.cursorId);
|
||||
let next;
|
||||
if (curIdx === -1) {
|
||||
next = 0;
|
||||
} else {
|
||||
next = curIdx + direction;
|
||||
if (next < 0) next = 0;
|
||||
if (next >= items.length) next = items.length - 1;
|
||||
}
|
||||
state.cursorId = items[next].id;
|
||||
nextTick(() => ensureVisible());
|
||||
}
|
||||
|
||||
function ensureVisible() {
|
||||
if (!listWrapRef.value || !state.cursorId) return;
|
||||
const cursorEl = listWrapRef.value.querySelector(`.row[data-id="${state.cursorId}"]`);
|
||||
if (!cursorEl) return;
|
||||
const elRect = cursorEl.getBoundingClientRect();
|
||||
const wrapRect = listWrapRef.value.getBoundingClientRect();
|
||||
if (elRect.top < wrapRect.top + 30) {
|
||||
listWrapRef.value.scrollTop -= (wrapRect.top + 30 - elRect.top);
|
||||
} else if (elRect.bottom > wrapRect.bottom - 10) {
|
||||
listWrapRef.value.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Open detail ---
|
||||
|
||||
async function openDetail(m) {
|
||||
detailMemory.value = m;
|
||||
showSource.value = false;
|
||||
loadingMarkdown.value = true;
|
||||
detailMarkdown.value = null;
|
||||
|
||||
if (m.markdown === null && m.path) {
|
||||
m.markdown = await loadMemoryMarkdown(m.path);
|
||||
}
|
||||
detailMarkdown.value = m.markdown;
|
||||
loadingMarkdown.value = false;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailMemory.value = null;
|
||||
detailMarkdown.value = null;
|
||||
showSource.value = false;
|
||||
}
|
||||
|
||||
function toggleSourceView() {
|
||||
showSource.value = !showSource.value;
|
||||
}
|
||||
|
||||
// --- Row click ---
|
||||
|
||||
function onRowClick(m) {
|
||||
state.cursorId = m.id;
|
||||
openDetail(m);
|
||||
}
|
||||
|
||||
// --- Archive/restore with undo ---
|
||||
|
||||
const undoSnapshot = ref(null);
|
||||
let undoTimer = null;
|
||||
|
||||
async function doArchive(ids) {
|
||||
const targets = ids || (state.cursorId ? [state.cursorId] : []);
|
||||
if (!targets.length) return;
|
||||
undoSnapshot.value = { action: 'archive', ids: [...targets] };
|
||||
undoCountdown.value = 5;
|
||||
for (const id of targets) {
|
||||
await archiveMemory(id);
|
||||
}
|
||||
startUndoTimer();
|
||||
// Move cursor if needed
|
||||
if (targets.includes(state.cursorId)) {
|
||||
const items = visibleMemories.value;
|
||||
if (items.length) state.cursorId = items[0].id;
|
||||
else state.cursorId = null;
|
||||
}
|
||||
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function doRestore(ids) {
|
||||
const targets = ids || (state.cursorId ? [state.cursorId] : []);
|
||||
if (!targets.length) return;
|
||||
undoSnapshot.value = { action: 'restore', ids: [...targets] };
|
||||
undoCountdown.value = 5;
|
||||
for (const id of targets) {
|
||||
await restoreMemory(id);
|
||||
}
|
||||
startUndoTimer();
|
||||
if (targets.includes(state.cursorId)) {
|
||||
const items = visibleMemories.value;
|
||||
if (items.length) state.cursorId = items[0].id;
|
||||
else state.cursorId = null;
|
||||
}
|
||||
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoAction() {
|
||||
if (!undoSnapshot.value) return;
|
||||
const { action, ids } = undoSnapshot.value;
|
||||
for (const id of ids) {
|
||||
if (action === 'archive') await restoreMemory(id);
|
||||
else await archiveMemory(id);
|
||||
}
|
||||
undoSnapshot.value = null;
|
||||
undoCountdown.value = 0;
|
||||
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||
}
|
||||
|
||||
function startUndoTimer() {
|
||||
if (undoTimer) clearInterval(undoTimer);
|
||||
undoCountdown.value = 5;
|
||||
undoTimer = setInterval(() => {
|
||||
undoCountdown.value--;
|
||||
if (undoCountdown.value <= 0) {
|
||||
clearInterval(undoTimer);
|
||||
undoTimer = null;
|
||||
undoSnapshot.value = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// --- Detail action ---
|
||||
|
||||
function detailArchiveRestore() {
|
||||
if (!detailMemory.value) return;
|
||||
if (detailMemory.value.archived) {
|
||||
doRestore([detailMemory.value.id]);
|
||||
} else {
|
||||
doArchive([detailMemory.value.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detail markdown rendering ---
|
||||
|
||||
const renderedMarkdown = computed(() => {
|
||||
if (detailMarkdown.value == null) return null;
|
||||
if (showSource.value) return null; // handled by pre block in template
|
||||
return renderMarkdown(detailMarkdown.value, { variant: 'body' });
|
||||
});
|
||||
|
||||
// --- Keyboard handler ---
|
||||
|
||||
function onKeydown(e) {
|
||||
// Do not handle if user is typing in an input
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (showDetail.value) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); closeDetail(); return; }
|
||||
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); detailArchiveRestore(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
moveCursor(1);
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
moveCursor(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (state.cursorId) {
|
||||
const m = visibleMemories.value.find(x => x.id === state.cursorId);
|
||||
if (m) openDetail(m);
|
||||
}
|
||||
break;
|
||||
case 'x':
|
||||
e.preventDefault();
|
||||
if (state.cursorId) toggleSelection(state.cursorId);
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
e.preventDefault();
|
||||
if (state.view === 'archived') {
|
||||
doRestore(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
|
||||
} else {
|
||||
doArchive(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
|
||||
}
|
||||
break;
|
||||
case 'z':
|
||||
if ((e.metaKey || e.ctrlKey) && undoSnapshot.value) {
|
||||
e.preventDefault();
|
||||
undoAction();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Detail panel overlay -->
|
||||
<div v-if="showDetail" class="detail-wrap">
|
||||
<div class="detail">
|
||||
<div class="detail-header">
|
||||
<div class="detail-eyebrow">
|
||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
|
||||
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
|
||||
</div>
|
||||
<div class="detail-path">{{ detailMemory.path }}</div>
|
||||
<div class="detail-summary">{{ detailMemory.summary }}</div>
|
||||
<div class="detail-meta">
|
||||
<span>{{ fmtRelative(detailMemory.ts) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="markdown-section">
|
||||
<div class="markdown-toolbar">
|
||||
<span class="markdown-toolbar-label">Body</span>
|
||||
<button
|
||||
class="source-toggle"
|
||||
:class="{ active: showSource }"
|
||||
:disabled="detailMarkdown == null"
|
||||
@click="toggleSourceView"
|
||||
>
|
||||
{{ showSource ? 'Show rendered' : 'Show source' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMarkdown" class="markdown-loading">Loading...</div>
|
||||
<div v-else-if="detailMarkdown == null" class="markdown-empty">
|
||||
File not found or empty.
|
||||
</div>
|
||||
<pre v-else-if="showSource" class="markdown-source">{{ detailMarkdown }}</pre>
|
||||
<div v-else class="markdown-body" v-html="renderedMarkdown"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="btn" @click="closeDetail">
|
||||
Back<span class="kbd">Esc</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:class="detailMemory.archived ? 'primary' : 'danger'"
|
||||
@click="detailArchiveRestore"
|
||||
>
|
||||
{{ detailMemory.archived ? 'Restore' : 'Archive' }}<span class="kbd">D</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List panel -->
|
||||
<div v-else ref="listWrapRef" class="list-wrap">
|
||||
<div v-if="!visibleMemories.length" class="empty">
|
||||
No memories{{ state.view === 'archived' ? ' archived' : '' }} here.
|
||||
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="memory-list">
|
||||
<div
|
||||
v-for="m in visibleMemories"
|
||||
:key="m.id"
|
||||
class="row"
|
||||
:class="{
|
||||
cursor: state.cursorId === m.id,
|
||||
selected: state.selection.has(m.id),
|
||||
archived: m.archived
|
||||
}"
|
||||
:data-id="m.id"
|
||||
@click="onRowClick(m)"
|
||||
>
|
||||
<button
|
||||
class="row-checkbox"
|
||||
:class="{ checked: state.selection.has(m.id) }"
|
||||
aria-label="Select"
|
||||
@click.stop="toggleSelection(m.id)"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
||||
<path d="M2.5 6.5l2.5 2.5 4.5-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="row-body">
|
||||
<div class="row-path">
|
||||
<span
|
||||
v-if="dominantRowStatus(m)"
|
||||
class="row-status"
|
||||
:class="dominantRowStatus(m)"
|
||||
:title="dominantRowStatus(m)"
|
||||
v-html="statusGlyphs(dominantRowStatus(m))"
|
||||
></span>
|
||||
<template v-if="showProjectPrefix">
|
||||
<span class="project-prefix" v-html="projectLabel(m)"></span>
|
||||
<span class="project-prefix-sep">/</span>
|
||||
</template>
|
||||
<span class="path-text" v-html="pathHTML(m)"></span>
|
||||
</div>
|
||||
<div class="row-summary" v-html="summaryHTML(m)"></div>
|
||||
</div>
|
||||
|
||||
<div class="row-right">
|
||||
<div class="row-meta"><span>{{ timeLabel(m) }}</span></div>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
v-if="m.archived"
|
||||
class="row-action restore"
|
||||
@click.stop="doRestore([m.id])"
|
||||
>
|
||||
Restore<span class="kbd">D</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="row-action danger"
|
||||
@click.stop="doArchive([m.id])"
|
||||
>
|
||||
Archive<span class="kbd">D</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Undo toast -->
|
||||
<Transition name="undo-fade">
|
||||
<div v-if="undoSnapshot" class="undo-toast" @click="undoAction">
|
||||
{{ undoSnapshot.action === 'archive' ? 'Archived' : 'Restored' }}
|
||||
{{ undoSnapshot.ids.length }} memory{{ undoSnapshot.ids.length > 1 ? 'ies' : '' }}.
|
||||
<button class="undo-btn">Undo ({{ undoCountdown }}s)</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 32px 60px;
|
||||
}
|
||||
|
||||
.memory-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Row styles */
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr auto;
|
||||
align-items: start;
|
||||
column-gap: 12px;
|
||||
padding: 14px 16px 14px 14px;
|
||||
min-height: var(--row-h, 60px);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s;
|
||||
position: relative;
|
||||
}
|
||||
.row:last-child { border-bottom: 0; }
|
||||
.row:hover { background: rgba(255,255,255,0.025); }
|
||||
.row.cursor { background: var(--surface); }
|
||||
.row.cursor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-2);
|
||||
}
|
||||
.row.selected { background: var(--accent-soft); }
|
||||
.row.selected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.row.cursor.selected { background: rgba(167,139,250,0.16); }
|
||||
|
||||
.row-checkbox {
|
||||
width: 18px; height: 18px; margin-top: 1px;
|
||||
border-radius: 4px;
|
||||
border: 1.5px solid var(--muted-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
transition: all 0.1s;
|
||||
justify-self: center;
|
||||
}
|
||||
.row:hover .row-checkbox,
|
||||
.row.selected .row-checkbox,
|
||||
.row.cursor .row-checkbox { opacity: 1; }
|
||||
.row-checkbox:hover { border-color: var(--accent); }
|
||||
.row-checkbox.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; }
|
||||
.row-checkbox.checked svg { opacity: 1; }
|
||||
|
||||
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.row-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px; height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-status :deep(svg) { width: 100%; height: 100%; }
|
||||
.row-status.broken { color: var(--danger); }
|
||||
.row-status.partial { color: var(--warn); }
|
||||
.row-status.archived { color: var(--muted-2); }
|
||||
|
||||
.row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; }
|
||||
.row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; }
|
||||
.row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.row-path :deep(mark), .row-summary :deep(mark) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.row-summary {
|
||||
font-size: var(--text-base);
|
||||
color: var(--fg-2);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.row-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.row:hover .row-meta { color: var(--muted-2); }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; }
|
||||
.row:hover .row-actions, .row.cursor .row-actions { opacity: 1; }
|
||||
|
||||
.row-action {
|
||||
height: 24px; padding: 0 8px; border-radius: 4px;
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
transition: all 0.1s; border: 1px solid transparent;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
.row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); }
|
||||
.row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||
.row-action.restore { color: var(--accent-2); }
|
||||
.row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); }
|
||||
.row-action .kbd {
|
||||
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2);
|
||||
padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4;
|
||||
}
|
||||
.row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); }
|
||||
|
||||
.row.archived .row-path, .row.archived .row-summary { color: var(--muted); }
|
||||
.row.archived .row-path .project-prefix { color: var(--muted-2); }
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted-2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .hint { font-size: 11px; color: var(--muted-2); }
|
||||
|
||||
/* Detail panel styles */
|
||||
.detail-header { margin-bottom: 24px; }
|
||||
.detail-eyebrow {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); display: inline-flex; }
|
||||
.detail-eyebrow .project-icon :deep(svg) { width: 100%; height: 100%; }
|
||||
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||
.detail-eyebrow .archived-tag {
|
||||
color: var(--accent-2);
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.detail-eyebrow .archived-tag::before {
|
||||
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
.detail-path {
|
||||
font-family: var(--font-mono); font-size: 17px; font-weight: 500;
|
||||
color: var(--fg); line-height: 1.5;
|
||||
word-break: break-all; margin-bottom: 16px;
|
||||
}
|
||||
.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; }
|
||||
.detail-meta {
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.markdown-section { margin: 28px 0 8px; }
|
||||
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.markdown-toolbar-label {
|
||||
font-size: 10.5px; color: var(--muted);
|
||||
font-weight: 500; letter-spacing: 0.04em; flex: 1;
|
||||
}
|
||||
.source-toggle {
|
||||
height: 22px; padding: 0 8px; border-radius: 4px;
|
||||
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||
color: var(--muted); font-size: var(--text-sm);
|
||||
transition: all 0.1s; cursor: pointer;
|
||||
}
|
||||
.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||
.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||
.source-toggle:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.markdown-loading {
|
||||
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||
}
|
||||
.markdown-empty {
|
||||
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||
border: 1px dashed var(--hairline); border-radius: 6px;
|
||||
}
|
||||
.markdown-source {
|
||||
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline);
|
||||
border-radius: 6px; padding: 14px 16px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.55;
|
||||
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||
}
|
||||
|
||||
.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.detail-actions .btn {
|
||||
height: 30px; padding: 0 14px; border-radius: 6px;
|
||||
font-size: var(--text-base); font-weight: 500;
|
||||
transition: all 0.1s;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2); background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); }
|
||||
.detail-actions .btn.danger { color: var(--danger); }
|
||||
.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); }
|
||||
.detail-actions .btn.primary { color: var(--accent-2); }
|
||||
.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); }
|
||||
.detail-actions .btn .kbd {
|
||||
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||
padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Undo toast */
|
||||
.undo-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-strong);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--fg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
z-index: 100;
|
||||
cursor: pointer;
|
||||
}
|
||||
.undo-btn {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid rgba(167,139,250,0.3);
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--accent-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.undo-btn:hover { background: rgba(167,139,250,0.25); border-color: var(--accent); }
|
||||
|
||||
.undo-fade-enter-active, .undo-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.undo-fade-enter-from, .undo-fade-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
import {
|
||||
escapeHTML,
|
||||
fmtRelative,
|
||||
fmtClockTime,
|
||||
renderMarkdown,
|
||||
formatProjectLabel
|
||||
} from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'SessionDetail' });
|
||||
const props = defineProps({ id: String });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// --- Reactive state ---
|
||||
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const messages = ref([]);
|
||||
const loading = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const showBackToTop = ref(false);
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
const detailRef = ref(null);
|
||||
|
||||
// --- Load session on mount ---
|
||||
onMounted(async () => {
|
||||
await loadMessages();
|
||||
});
|
||||
|
||||
// When keep-alive re-activates, re-check if we need data
|
||||
onActivated(async () => {
|
||||
if (messages.value.length === 0 && props.id) {
|
||||
await loadMessages();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMessages() {
|
||||
if (!props.id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const s = state.sessions.find(x => x.id === props.id);
|
||||
if (s && (!s.messages || s.messages.length === 0)) {
|
||||
const loaded = await loadSessionDetail(props.id);
|
||||
if (loaded) Object.assign(s, loaded);
|
||||
}
|
||||
messages.value = s?.messages || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// Focus pending uuid if any
|
||||
if (state.pendingFocusUuid) {
|
||||
const targetUuid = state.pendingFocusUuid;
|
||||
state.pendingFocusUuid = null;
|
||||
await nextTick();
|
||||
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.classList.add('is-focused');
|
||||
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scroll / progress tracking ---
|
||||
function onScroll() {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card');
|
||||
if (!msgs.length) return;
|
||||
const wrapTop = wrapRef.value.getBoundingClientRect().top;
|
||||
let topMsgIdx = 0;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||
else break;
|
||||
}
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||
progressPct.value = pct;
|
||||
showBackToTop.value = wrapRef.value.scrollTop > 300;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// --- Toggle helpers ---
|
||||
function toggleToolCall(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-tool').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleSummary(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-summary').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleThinking(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-thinking').classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleMeta(event) {
|
||||
const btn = event.currentTarget;
|
||||
btn.closest('.msg-meta-collapsed').classList.toggle('open');
|
||||
}
|
||||
|
||||
// --- Full text loading ---
|
||||
async function handleLoadFullText(event, uuid) {
|
||||
const btn = event.currentTarget;
|
||||
btn.textContent = 'Loading...';
|
||||
const fullText = await loadFullText(uuid);
|
||||
if (fullText) {
|
||||
const msgEl = btn.closest('.msg');
|
||||
const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact');
|
||||
const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg';
|
||||
const rendered = renderMarkdown(fullText, { variant, query: state.query });
|
||||
if (bodyEl) bodyEl.outerHTML = rendered;
|
||||
btn.remove();
|
||||
} else {
|
||||
btn.textContent = 'Failed to load full text';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subagent navigation ---
|
||||
function navigateToSubagent(agentId, description) {
|
||||
router.push({
|
||||
name: 'SubagentDetail',
|
||||
params: { id: props.id, agentId }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
||||
|
||||
function getArgPreview(tc) {
|
||||
try {
|
||||
const j = JSON.parse(tc.input_json || '{}');
|
||||
if (j.file_path) return j.file_path;
|
||||
if (j.command) return j.command;
|
||||
if (j.path) return j.path;
|
||||
if (j.description) return j.description;
|
||||
return JSON.stringify(j).slice(0, 100);
|
||||
} catch {
|
||||
return (tc.input_json || '').slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallParsedInput(tc) {
|
||||
try {
|
||||
return JSON.parse(tc.input_json || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll">
|
||||
<div class="detail" ref="detailRef">
|
||||
<!-- Progress bar -->
|
||||
<div class="session-progress">
|
||||
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="empty" style="padding: 60px 0; text-align: center; color: var(--muted);">
|
||||
Loading session...
|
||||
</div>
|
||||
|
||||
<!-- Session header -->
|
||||
<template v-if="session && !loading">
|
||||
<div class="session-header">
|
||||
<div class="session-eyebrow">
|
||||
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span class="project-path">{{ session.project_path || '' }}</span>
|
||||
</div>
|
||||
<div class="session-title">{{ session.title || '(untitled)' }}</div>
|
||||
<div class="session-meta-inline">
|
||||
<span>{{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ session.message_count || 0 }} messages</span>
|
||||
<template v-if="session.git_branch">
|
||||
<span class="dot"></span>
|
||||
<span>{{ session.git_branch }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message timeline -->
|
||||
<div class="timeline">
|
||||
<template v-for="(msg, idx) in messages" :key="msg.uuid || idx">
|
||||
|
||||
<!-- Meta messages: collapsed system indicator -->
|
||||
<template v-if="msg.is_meta === 1">
|
||||
<div class="msg meta" :data-uuid="msg.uuid">
|
||||
<div class="msg-meta-collapsed">
|
||||
<button class="meta-toggle" @click="toggleMeta">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="meta-label">System</span>
|
||||
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
||||
</button>
|
||||
<div class="meta-body">
|
||||
<div v-html="renderMarkdown(msg.text, { variant: 'compact', query: state.query })"></div>
|
||||
<button
|
||||
v-if="isTextTruncated(msg.text)"
|
||||
class="truncated-btn"
|
||||
@click="handleLoadFullText($event, msg.uuid)"
|
||||
>Message truncated — click to load full text</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Workflow card (standalone, outside assistant bubble) -->
|
||||
<template v-else-if="!msg.type || msg.type !== 'user' ? (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow) : false">
|
||||
<template v-if="(() => { const wfCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); return wfCall && msg.type !== 'user'; })()">
|
||||
<div class="wf-card" :data-uuid="msg.uuid">
|
||||
<div class="wf-card-header">
|
||||
<span class="wf-card-icon">⚙</span>
|
||||
<span class="wf-card-name">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.workflow_name || 'Workflow' }}</span>
|
||||
<span class="wf-card-count">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.agents?.length || 0 }} agents</span>
|
||||
<span
|
||||
v-if="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||
class="wf-card-status"
|
||||
:class="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||
>{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status }}</span>
|
||||
</div>
|
||||
<div class="wf-card-body">
|
||||
<!-- Group agents by phase -->
|
||||
<template v-for="(phaseAgents, phase) in (() => {
|
||||
const wf = ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow;
|
||||
const phases = {};
|
||||
for (const a of (wf.agents || [])) {
|
||||
const p = a.phase || 'Other';
|
||||
if (!phases[p]) phases[p] = [];
|
||||
phases[p].push(a);
|
||||
}
|
||||
return phases;
|
||||
})()" :key="phase">
|
||||
<div class="wf-card-phase">
|
||||
<div class="wf-card-phase-title">{{ phase }}</div>
|
||||
<button
|
||||
v-for="a in phaseAgents"
|
||||
:key="a.agent_id"
|
||||
class="wf-card-agent"
|
||||
@click="navigateToSubagent(a.agent_id, a.label || '')"
|
||||
>
|
||||
<span class="wf-card-agent-label">{{ a.label || a.agent_id }}</span>
|
||||
<span v-if="a.state === 'error'" class="wf-card-agent-state error">error</span>
|
||||
<span class="wf-card-agent-arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Other tool calls (non-workflow) for this message -->
|
||||
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'">
|
||||
<div class="msg-tools">
|
||||
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
||||
<!-- Render non-workflow tool calls -->
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ tc.input_json || '' }}</pre>
|
||||
<template v-if="tc.result">
|
||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Standalone thinking message -->
|
||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
||||
<div class="msg-thinking">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
</button>
|
||||
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Normal message (user or assistant) -->
|
||||
<template v-else>
|
||||
<div
|
||||
class="msg"
|
||||
:class="msg.type === 'user' ? 'user' : 'assistant'"
|
||||
:data-uuid="msg.uuid"
|
||||
>
|
||||
<!-- Message header -->
|
||||
<div class="msg-head">
|
||||
<span class="role">{{ msg.type === 'user' ? 'You' : 'Assistant' }}</span>
|
||||
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
||||
<div v-if="msg._thinking" class="msg-thinking">
|
||||
<button class="thinking-toggle" @click="toggleThinking">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="thinking-label">Thinking</span>
|
||||
</button>
|
||||
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })"></div>
|
||||
</div>
|
||||
|
||||
<!-- Message text body -->
|
||||
<template v-if="msg.text">
|
||||
<div v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||
<button
|
||||
v-if="isTextTruncated(msg.text)"
|
||||
class="truncated-btn"
|
||||
@click="handleLoadFullText($event, msg.uuid)"
|
||||
>Message truncated — click to load full text</button>
|
||||
</template>
|
||||
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
|
||||
<div class="msg-text empty-text">(no text content)</div>
|
||||
</template>
|
||||
|
||||
<!-- Tool calls -->
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
|
||||
<template v-for="tc in msg.tool_calls" :key="tc.id">
|
||||
|
||||
<!-- Agent/Task tool call (subagent) -->
|
||||
<template v-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||
<div class="msg-tool agent-call">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
||||
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
<button
|
||||
v-if="tc.subagent?.agent_id"
|
||||
class="agent-nav-btn"
|
||||
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
|
||||
>View conversation →</button>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<template v-if="getToolCallParsedInput(tc).prompt">
|
||||
<div class="tc-section">Prompt</div>
|
||||
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
|
||||
</template>
|
||||
<template v-if="tc.result?.content">
|
||||
<div class="tc-section">Result</div>
|
||||
<div class="agent-result" v-html="renderMarkdown(tc.result.content, { variant: 'compact' })"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Workflow tool call (inside assistant bubble) -->
|
||||
<template v-else-if="tc.name === 'Workflow'">
|
||||
<div class="msg-tool agent-call">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">Workflow</span>
|
||||
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
|
||||
<span
|
||||
v-if="tc.workflow?.status"
|
||||
class="workflow-status"
|
||||
:class="tc.workflow.status"
|
||||
>{{ tc.workflow.status }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<template v-if="tc.workflow?.agents?.length">
|
||||
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||
<div class="workflow-agent-list">
|
||||
<template v-for="(phaseAgents, phase) in (() => {
|
||||
const phases = {};
|
||||
for (const a of (tc.workflow.agents || [])) {
|
||||
const p = a.phase || 'Other';
|
||||
if (!phases[p]) phases[p] = [];
|
||||
phases[p].push(a);
|
||||
}
|
||||
return phases;
|
||||
})()" :key="phase">
|
||||
<div class="workflow-phase-group">
|
||||
<div class="workflow-phase-header">{{ phase }}</div>
|
||||
<div class="workflow-phase-agents">
|
||||
<button
|
||||
v-for="a in phaseAgents"
|
||||
:key="a.agent_id"
|
||||
class="workflow-agent-row"
|
||||
@click.stop="navigateToSubagent(a.agent_id, a.label || '')"
|
||||
>
|
||||
<span class="workflow-agent-label">{{ a.label || a.agent_id }}</span>
|
||||
<span class="workflow-agent-state" :class="a.state || ''">{{ a.state || '' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Generic tool call -->
|
||||
<template v-else>
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="tool-name">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ tc.input_json || '' }}</pre>
|
||||
<template v-if="tc.result">
|
||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Summary block -->
|
||||
<div v-if="msg.summary" class="msg-summary">
|
||||
<button class="summary-toggle" @click="toggleSummary">
|
||||
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||
<span class="label">Session summary</span>
|
||||
<span class="source">{{ msg.summary.source || '' }}</span>
|
||||
</button>
|
||||
<div class="summary-body" v-html="renderMarkdown(msg.summary.content, { variant: 'compact' })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Back to top button -->
|
||||
<button
|
||||
class="back-to-top"
|
||||
:class="{ show: showBackToTop }"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state } from '../store.js';
|
||||
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime } from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'SessionList' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const visibleSessions = computed(() => {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
return state.sessions
|
||||
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||
.map(s => {
|
||||
if (!q) return { ...s, messageHit: null };
|
||||
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||
(s.project || '').toLowerCase().includes(q) ||
|
||||
(s.git_branch || '').toLowerCase().includes(q);
|
||||
if (topMatch) return { ...s, messageHit: null };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const ta = new Date(a.started_at || 0).getTime();
|
||||
const tb = new Date(b.started_at || 0).getTime();
|
||||
return state.sortDesc ? tb - ta : ta - tb;
|
||||
});
|
||||
});
|
||||
|
||||
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||
|
||||
function titleHTML(session) {
|
||||
return highlightPlain(session.title || '(untitled)', state.query.trim());
|
||||
}
|
||||
|
||||
function projectLabel(session) {
|
||||
return escapeHTML(formatProjectLabel(session.project));
|
||||
}
|
||||
|
||||
function timeLabel(session) {
|
||||
const ts = new Date(session.started_at || 0).getTime();
|
||||
return fmtListTime(ts);
|
||||
}
|
||||
|
||||
function openSession(session) {
|
||||
router.push({ name: 'SessionDetail', params: { id: session.id } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="session-list-wrap">
|
||||
<div v-if="!visibleSessions.length" class="empty">
|
||||
No sessions here.
|
||||
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||
</div>
|
||||
<div v-else class="session-list">
|
||||
<div
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
class="srow"
|
||||
:class="{ cursor: state.cursorId === s.id }"
|
||||
:data-session-id="s.id"
|
||||
@click="openSession(s)"
|
||||
>
|
||||
<div class="srow-body">
|
||||
<div class="srow-title" v-html="titleHTML(s)"></div>
|
||||
<div class="srow-meta">
|
||||
<template v-if="showProjectPrefix">
|
||||
<span class="project-tag" v-html="projectLabel(s)"></span>
|
||||
<span class="dot"></span>
|
||||
</template>
|
||||
<span>{{ s.message_count || 0 }} msg</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="srow-right">{{ timeLabel(s) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.session-list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.srow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
column-gap: 12px;
|
||||
padding: 12px 16px;
|
||||
min-height: var(--row-h-session);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
transition: background 0.06s;
|
||||
position: relative;
|
||||
}
|
||||
.srow:hover {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.srow.cursor {
|
||||
background: var(--surface);
|
||||
}
|
||||
.srow.cursor::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-2);
|
||||
}
|
||||
|
||||
.srow-body {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.srow-title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 500;
|
||||
color: var(--fg);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.srow-title :deep(mark) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-2);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.srow-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.srow-meta .project-tag {
|
||||
color: var(--fg-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.srow-meta .dot {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background: var(--muted-2);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.srow-right {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-2);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted-2);
|
||||
font-size: var(--text-sm);
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty .hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
defineOptions({ name: 'SubagentDetail' });
|
||||
defineProps({ id: String, agentId: String });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-placeholder">SubagentDetail view for agent {{ agentId }} in session {{ id }} (TODO)</div>
|
||||
</template>
|
||||
@@ -0,0 +1,770 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { state, navigateToSession } from '../store.js';
|
||||
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
||||
|
||||
defineOptions({ name: 'Usage' });
|
||||
|
||||
// --- State ---
|
||||
const activeTab = ref('daily');
|
||||
const loading = ref(true);
|
||||
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
||||
const selectedDayKey = ref(null);
|
||||
const loadedMonths = ref(0);
|
||||
const monthBlocks = ref([]);
|
||||
|
||||
// Tooltip
|
||||
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
||||
|
||||
// --- Constants ---
|
||||
const DAY_MS = 86400000;
|
||||
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const MONTHS_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
|
||||
// --- Computed: heatmap grid ---
|
||||
const heatmapGrid = computed(() => {
|
||||
const today = new Date();
|
||||
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
const values = usageData.daily.map(d => d.tokens).filter(Boolean);
|
||||
const maxTokens = Math.max(...values, 1);
|
||||
|
||||
const cells = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const date = new Date(startDate.getTime() + i * DAY_MS);
|
||||
if (date > today) break;
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
const tokens = dailyMap[key] || 0;
|
||||
const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
|
||||
const col = Math.floor(i / 7);
|
||||
const row = i % 7;
|
||||
cells.push({ key, tokens, level, col, row, date });
|
||||
}
|
||||
|
||||
const maxCol = cells.length ? cells[cells.length - 1].col : 0;
|
||||
const cellSize = 11;
|
||||
const cellGap = 2;
|
||||
const step = cellSize + cellGap;
|
||||
const gridWidth = (maxCol + 1) * step + 20;
|
||||
const gridHeight = 7 * step;
|
||||
|
||||
// Month labels
|
||||
const monthLabels = [];
|
||||
let lastMonth = -1;
|
||||
for (const c of cells) {
|
||||
const m = c.date.getMonth();
|
||||
if (m !== lastMonth && c.row === 0) {
|
||||
monthLabels.push({ col: c.col, label: MONTHS_SHORT[m] });
|
||||
lastMonth = m;
|
||||
}
|
||||
}
|
||||
|
||||
return { cells, monthLabels, gridWidth, gridHeight, cellSize, step };
|
||||
});
|
||||
|
||||
// --- Computed: streaks ---
|
||||
const currentStreak = computed(() => {
|
||||
const today = new Date();
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
let streak = 0;
|
||||
let startedCounting = false;
|
||||
for (let i = 0; i <= 365; i++) {
|
||||
const d = new Date(today.getTime() - i * DAY_MS).toISOString().slice(0, 10);
|
||||
if (dailyMap[d] && dailyMap[d] > 0) {
|
||||
startedCounting = true;
|
||||
streak++;
|
||||
} else if (startedCounting) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return streak;
|
||||
});
|
||||
|
||||
const longestStreak = computed(() => {
|
||||
const sortedDays = [...usageData.daily]
|
||||
.filter(d => d.tokens > 0)
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
|
||||
let longest = 0;
|
||||
let streak = 0;
|
||||
for (let i = 0; i < sortedDays.length; i++) {
|
||||
if (i === 0) {
|
||||
streak = 1;
|
||||
} else {
|
||||
const prev = new Date(sortedDays[i - 1].day).getTime();
|
||||
const curr = new Date(sortedDays[i].day).getTime();
|
||||
streak = (curr - prev === DAY_MS) ? streak + 1 : 1;
|
||||
}
|
||||
if (streak > longest) longest = streak;
|
||||
}
|
||||
return longest;
|
||||
});
|
||||
|
||||
// --- Computed: weekly chart ---
|
||||
const weeklyBars = computed(() => {
|
||||
const today = new Date();
|
||||
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||
|
||||
const dailyMap = {};
|
||||
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 53; w++) {
|
||||
const weekStart = new Date(startDate.getTime() + w * 7 * DAY_MS);
|
||||
if (weekStart > today) break;
|
||||
let tokens = 0;
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const date = new Date(weekStart.getTime() + d * DAY_MS);
|
||||
if (date > today) break;
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
tokens += dailyMap[key] || 0;
|
||||
}
|
||||
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
|
||||
}
|
||||
|
||||
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
|
||||
const barWidth = 10;
|
||||
const barGap = 3;
|
||||
const chartHeight = 120;
|
||||
const chartWidth = weeks.length * (barWidth + barGap);
|
||||
|
||||
const labels = [];
|
||||
let lastMonth = -1;
|
||||
for (let i = 0; i < weeks.length; i++) {
|
||||
const m = weeks[i].weekStart.getMonth();
|
||||
if (m !== lastMonth) { labels.push({ i, label: MONTHS_SHORT[m] }); lastMonth = m; }
|
||||
}
|
||||
|
||||
const bars = weeks.map((w, i) => {
|
||||
const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0;
|
||||
const x = i * (barWidth + barGap);
|
||||
return { x, y: chartHeight - h, width: barWidth, height: Math.max(h, 0.5), label: `Week of ${w.weekKey}: ${fmtTokens(w.tokens)}` };
|
||||
});
|
||||
|
||||
return { bars, labels, chartWidth, chartHeight, barWidth, barGap };
|
||||
});
|
||||
|
||||
// --- Computed: cumulative chart ---
|
||||
const cumulativeData = computed(() => {
|
||||
const sorted = [...usageData.daily].sort((a, b) => a.day.localeCompare(b.day));
|
||||
if (!sorted.length) return null;
|
||||
|
||||
let cumulative = 0;
|
||||
const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; });
|
||||
const maxVal = points[points.length - 1].total || 1;
|
||||
|
||||
const chartWidth = 700;
|
||||
const chartHeight = 140;
|
||||
|
||||
const xScale = (i) => (i / (points.length - 1)) * chartWidth;
|
||||
const yScale = (v) => chartHeight - (v / maxVal) * chartHeight;
|
||||
|
||||
const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`);
|
||||
const linePath = pathParts.join(' ');
|
||||
const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`;
|
||||
|
||||
const labels = [];
|
||||
let lastMonth = -1;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const m = new Date(points[i].day).getMonth();
|
||||
if (m !== lastMonth) { labels.push({ x: xScale(i), label: MONTHS_SHORT[m] }); lastMonth = m; }
|
||||
}
|
||||
|
||||
const dots = points.map((p, i) => ({
|
||||
cx: xScale(i).toFixed(1),
|
||||
cy: yScale(p.total).toFixed(1),
|
||||
label: `${p.day}: ${fmtTokens(p.total)} total`
|
||||
}));
|
||||
|
||||
return { linePath, areaPath, labels, dots, chartWidth, chartHeight };
|
||||
});
|
||||
|
||||
// --- Computed: day sessions ---
|
||||
const daySessions = computed(() => {
|
||||
if (!selectedDayKey.value) return null;
|
||||
const dateKey = selectedDayKey.value;
|
||||
const dayStart = dateKey + 'T00:00:00';
|
||||
const dayEnd = dateKey + 'T23:59:59';
|
||||
|
||||
const sessions = state.sessions.filter(s => {
|
||||
if (!s.started_at) return false;
|
||||
const end = s.ended_at || s.started_at;
|
||||
return s.started_at <= dayEnd && end >= dayStart;
|
||||
});
|
||||
|
||||
const classified = sessions.map(s => {
|
||||
const isNew = s.started_at.slice(0, 10) === dateKey;
|
||||
let kind = 'continued';
|
||||
if (isNew) {
|
||||
const hasEarlierSession = state.sessions.some(
|
||||
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||
);
|
||||
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||
}
|
||||
return { ...s, kind };
|
||||
});
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
dateLabel: fmtTooltipDate(dateKey),
|
||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
isEmpty: classified.length === 0
|
||||
};
|
||||
});
|
||||
|
||||
// --- Methods ---
|
||||
function switchTab(view) {
|
||||
activeTab.value = view;
|
||||
}
|
||||
|
||||
function onCellEnter(cell, event) {
|
||||
tooltip.text = `${fmtTokens(cell.tokens)} tokens on ${fmtTooltipDate(cell.key)}`;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onCellMove(event) {
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onCellLeave() {
|
||||
tooltip.show = false;
|
||||
}
|
||||
|
||||
function onCellClick(cell) {
|
||||
selectedDayKey.value = cell.key;
|
||||
}
|
||||
|
||||
function onBarEnter(bar, event) {
|
||||
tooltip.text = bar.label;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function onDotEnter(dot, event) {
|
||||
tooltip.text = dot.label;
|
||||
tooltip.show = true;
|
||||
updateTooltipPos(event);
|
||||
}
|
||||
|
||||
function updateTooltipPos(event) {
|
||||
const pad = 12;
|
||||
let left = event.clientX + pad;
|
||||
if (left + 200 > window.innerWidth - pad) left = event.clientX - 200 - pad;
|
||||
tooltip.x = left;
|
||||
tooltip.y = event.clientY - 28;
|
||||
}
|
||||
|
||||
function goToSession(sessionId) {
|
||||
navigateToSession(sessionId);
|
||||
}
|
||||
|
||||
function buildMonthBlock(year, month) {
|
||||
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`;
|
||||
|
||||
const monthSessions = state.sessions.filter(s => {
|
||||
if (!s.started_at) return false;
|
||||
const end = s.ended_at || s.started_at;
|
||||
return s.started_at < nextMonth && end >= monthStart;
|
||||
});
|
||||
|
||||
const classified = monthSessions.map(s => {
|
||||
const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth;
|
||||
let kind = 'continued';
|
||||
if (startedInMonth) {
|
||||
const hasEarlierSession = state.sessions.some(
|
||||
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||
);
|
||||
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||
}
|
||||
return { ...s, kind };
|
||||
});
|
||||
|
||||
return {
|
||||
header: `${MONTHS_FULL[month]} ${year}`,
|
||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
isEmpty: classified.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
function showNextMonth() {
|
||||
const today = new Date();
|
||||
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
|
||||
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
||||
monthBlocks.value.push(block);
|
||||
loadedMonths.value++;
|
||||
}
|
||||
|
||||
function projectLabel(project) {
|
||||
return formatProjectLabel(project);
|
||||
}
|
||||
|
||||
function newSessionProjectCount(sessions) {
|
||||
const projects = new Set(sessions.map(s => s.project || '(none)'));
|
||||
return projects.size;
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await window.obelisk.getUsageStats();
|
||||
usageData.daily = data.daily || [];
|
||||
usageData.totalTokens = data.totalTokens || 0;
|
||||
usageData.peakDay = data.peakDay || null;
|
||||
usageData.longestTurn = data.longestTurn || null;
|
||||
} catch (e) {
|
||||
console.error('Failed to load usage stats:', e);
|
||||
}
|
||||
loading.value = false;
|
||||
showNextMonth();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usage-wrap" v-if="!loading">
|
||||
<div class="detail-wide">
|
||||
<!-- Header with tabs -->
|
||||
<div class="usage-header">
|
||||
<span class="usage-title">Token activity</span>
|
||||
<div class="usage-view-tabs">
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'daily' }"
|
||||
@click="switchTab('daily')"
|
||||
>Daily</button>
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'weekly' }"
|
||||
@click="switchTab('weekly')"
|
||||
>Weekly</button>
|
||||
<button
|
||||
class="usage-tab"
|
||||
:class="{ active: activeTab === 'cumulative' }"
|
||||
@click="switchTab('cumulative')"
|
||||
>Cumulative</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="usage-stats">
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ fmtTokens(usageData.totalTokens) }}</span>
|
||||
<span class="usage-stat-label">Lifetime tokens</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ usageData.peakDay ? fmtTokens(usageData.peakDay.tokens) : '—' }}</span>
|
||||
<span class="usage-stat-label">Peak tokens</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ usageData.longestTurn ? fmtDuration(usageData.longestTurn.turn_duration_ms) : '—' }}</span>
|
||||
<span class="usage-stat-label">Longest task</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ currentStreak }}d</span>
|
||||
<span class="usage-stat-label">Current streak</span>
|
||||
</div>
|
||||
<div class="usage-stat">
|
||||
<span class="usage-stat-value">{{ longestStreak }}d</span>
|
||||
<span class="usage-stat-label">Longest streak</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily heatmap -->
|
||||
<div class="heatmap-container" v-show="activeTab === 'daily'">
|
||||
<svg
|
||||
class="heatmap"
|
||||
:width="heatmapGrid.gridWidth"
|
||||
:height="heatmapGrid.gridHeight + 20"
|
||||
:viewBox="`0 0 ${heatmapGrid.gridWidth} ${heatmapGrid.gridHeight + 20}`"
|
||||
>
|
||||
<rect
|
||||
v-for="cell in heatmapGrid.cells"
|
||||
:key="cell.key"
|
||||
:x="cell.col * heatmapGrid.step"
|
||||
:y="cell.row * heatmapGrid.step"
|
||||
:width="heatmapGrid.cellSize"
|
||||
:height="heatmapGrid.cellSize"
|
||||
rx="2"
|
||||
:class="['heatmap-cell', `level-${cell.level}`, { selected: selectedDayKey === cell.key }]"
|
||||
@mouseenter="onCellEnter(cell, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
@click="onCellClick(cell)"
|
||||
/>
|
||||
<text
|
||||
v-for="ml in heatmapGrid.monthLabels"
|
||||
:key="'ml-' + ml.col"
|
||||
:x="ml.col * heatmapGrid.step"
|
||||
:y="heatmapGrid.gridHeight + 14"
|
||||
class="heatmap-month"
|
||||
>{{ ml.label }}</text>
|
||||
</svg>
|
||||
<div class="heatmap-legend">
|
||||
<span class="heatmap-legend-label">Less</span>
|
||||
<svg width="70" height="11">
|
||||
<rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/>
|
||||
<rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/>
|
||||
<rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/>
|
||||
<rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/>
|
||||
<rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/>
|
||||
</svg>
|
||||
<span class="heatmap-legend-label">More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly bar chart -->
|
||||
<div class="chart-container" v-show="activeTab === 'weekly'">
|
||||
<svg
|
||||
class="weekly-chart"
|
||||
:viewBox="`0 0 ${weeklyBars.chartWidth + 20} ${weeklyBars.chartHeight + 24}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<rect
|
||||
v-for="(bar, i) in weeklyBars.bars"
|
||||
:key="'bar-' + i"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.width"
|
||||
:height="bar.height"
|
||||
rx="2"
|
||||
class="bar-fill"
|
||||
@mouseenter="onBarEnter(bar, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
/>
|
||||
<text
|
||||
v-for="(lbl, i) in weeklyBars.labels"
|
||||
:key="'wlbl-' + i"
|
||||
:x="lbl.i * (weeklyBars.barWidth + weeklyBars.barGap)"
|
||||
:y="weeklyBars.chartHeight + 16"
|
||||
class="heatmap-month"
|
||||
>{{ lbl.label }}</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Cumulative line chart -->
|
||||
<div class="chart-container" v-show="activeTab === 'cumulative'">
|
||||
<template v-if="cumulativeData">
|
||||
<svg
|
||||
:viewBox="`0 0 ${cumulativeData.chartWidth} ${cumulativeData.chartHeight + 24}`"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class="cumulative-chart"
|
||||
>
|
||||
<path :d="cumulativeData.areaPath" class="cumulative-area"/>
|
||||
<path :d="cumulativeData.linePath" class="cumulative-line"/>
|
||||
<circle
|
||||
v-for="(dot, i) in cumulativeData.dots"
|
||||
:key="'dot-' + i"
|
||||
:cx="dot.cx"
|
||||
:cy="dot.cy"
|
||||
r="6"
|
||||
class="cumulative-dot"
|
||||
@mouseenter="onDotEnter(dot, $event)"
|
||||
@mousemove="onCellMove"
|
||||
@mouseleave="onCellLeave"
|
||||
/>
|
||||
<text
|
||||
v-for="(lbl, i) in cumulativeData.labels"
|
||||
:key="'clbl-' + i"
|
||||
:x="lbl.x"
|
||||
:y="cumulativeData.chartHeight + 16"
|
||||
class="heatmap-month"
|
||||
>{{ lbl.label }}</text>
|
||||
</svg>
|
||||
</template>
|
||||
<div v-else class="empty">No data</div>
|
||||
</div>
|
||||
|
||||
<!-- Day sessions panel (from heatmap click) -->
|
||||
<div class="day-sessions" v-if="daySessions">
|
||||
<div class="day-sessions-header">{{ daySessions.dateLabel }}<template v-if="daySessions.isEmpty"> — no sessions</template></div>
|
||||
<div class="day-activity-timeline" v-if="!daySessions.isEmpty">
|
||||
<!-- New workspaces -->
|
||||
<div class="activity-group" v-if="daySessions.newWorkspaces.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</span>
|
||||
<span class="activity-group-title">Created {{ daySessions.newWorkspaces.length }} new workspace{{ daySessions.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.newWorkspaces"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New sessions -->
|
||||
<div class="activity-group" v-if="daySessions.newSessions.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon new">+</span>
|
||||
<span class="activity-group-title">Started {{ daySessions.newSessions.length }} session{{ daySessions.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(daySessions.newSessions) }} project{{ newSessionProjectCount(daySessions.newSessions) > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.newSessions"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Continued sessions -->
|
||||
<div class="activity-group continued" v-if="daySessions.continued.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon continued">↳</span>
|
||||
<span class="activity-group-title">Continued {{ daySessions.continued.length }} session{{ daySessions.continued.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in daySessions.continued"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly activity blocks -->
|
||||
<div class="day-sessions" v-if="!selectedDayKey">
|
||||
<template v-for="(block, bi) in monthBlocks" :key="bi">
|
||||
<div class="day-sessions-header">{{ block.header }}</div>
|
||||
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
||||
<div class="activity-group" v-if="block.newWorkspaces.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</span>
|
||||
<span class="activity-group-title">Created {{ block.newWorkspaces.length }} new workspace{{ block.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.newWorkspaces"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-group" v-if="block.newSessions.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon new">+</span>
|
||||
<span class="activity-group-title">Started {{ block.newSessions.length }} session{{ block.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(block.newSessions) }} project{{ newSessionProjectCount(block.newSessions) > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.newSessions"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-group continued" v-if="block.continued.length">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon continued">↳</span>
|
||||
<span class="activity-group-title">Continued {{ block.continued.length }} session{{ block.continued.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="activity-group-items">
|
||||
<button
|
||||
v-for="s in block.continued"
|
||||
:key="s.id"
|
||||
class="activity-item"
|
||||
@click="goToSession(s.id)"
|
||||
>
|
||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
|
||||
</template>
|
||||
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="chart-tooltip"
|
||||
:class="{ show: tooltip.show }"
|
||||
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
|
||||
>{{ tooltip.text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.usage-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; }
|
||||
|
||||
.usage-view-tabs { display: flex; gap: 0; }
|
||||
.usage-tab {
|
||||
padding: 5px 12px; font-size: 12px; font-family: var(--font-mono);
|
||||
color: var(--muted); background: transparent;
|
||||
border: 1px solid var(--hairline); cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
.usage-tab:first-child { border-radius: 4px 0 0 4px; }
|
||||
.usage-tab:last-child { border-radius: 0 4px 4px 0; }
|
||||
.usage-tab:not(:first-child) { border-left: 0; }
|
||||
.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||
.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||
|
||||
.usage-stats {
|
||||
display: flex; gap: 0; margin-bottom: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface); border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
}
|
||||
.usage-stat {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
gap: 4px; padding: 16px 12px;
|
||||
border-right: 1px solid var(--hairline);
|
||||
}
|
||||
.usage-stat:last-child { border-right: 0; }
|
||||
.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; }
|
||||
|
||||
.heatmap-container { margin-top: 8px; }
|
||||
.heatmap { display: block; width: 100%; height: auto; }
|
||||
.heatmap-cell { transition: opacity 0.08s; cursor: pointer; }
|
||||
.heatmap-cell.level-0 { fill: var(--surface-strong); }
|
||||
.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); }
|
||||
.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); }
|
||||
.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); }
|
||||
.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); }
|
||||
.heatmap-cell:hover { opacity: 0.7; }
|
||||
.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; }
|
||||
.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 12px; justify-content: flex-end;
|
||||
}
|
||||
.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); }
|
||||
|
||||
/* Chart container (weekly / cumulative) */
|
||||
.chart-container { margin-top: 8px; overflow-x: auto; }
|
||||
.chart-container svg { display: block; width: 100%; max-height: 160px; }
|
||||
|
||||
.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; cursor: pointer; }
|
||||
.bar-fill:hover { opacity: 1; }
|
||||
|
||||
.cumulative-area { fill: rgba(99, 102, 241, 0.12); }
|
||||
.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||||
.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; cursor: pointer; }
|
||||
.cumulative-dot:hover { opacity: 1; }
|
||||
|
||||
/* Chart tooltip */
|
||||
.chart-tooltip {
|
||||
position: fixed; z-index: 200;
|
||||
padding: 5px 10px; border-radius: 4px;
|
||||
background: rgba(30, 35, 50, 0.95);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
color: var(--fg-2);
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
pointer-events: none; opacity: 0;
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.chart-tooltip.show { opacity: 1; }
|
||||
|
||||
/* Day sessions panel */
|
||||
.day-sessions { margin-top: 24px; }
|
||||
.day-sessions-header {
|
||||
font-size: 14px; font-weight: 600; color: var(--fg);
|
||||
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.day-sessions-header:first-child { margin-top: 0; }
|
||||
|
||||
.day-activity-timeline {
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||
}
|
||||
|
||||
.activity-group { position: relative; }
|
||||
.activity-group-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; flex-shrink: 0;
|
||||
margin-left: -28px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||||
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
||||
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
||||
|
||||
.activity-group-title { font-size: 13px; }
|
||||
.activity-group.continued .activity-group-title { color: var(--muted); }
|
||||
|
||||
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||
.activity-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 5px;
|
||||
background: transparent; border: 0;
|
||||
cursor: pointer; transition: background 0.08s;
|
||||
text-align: left; width: 100%;
|
||||
font: inherit; color: inherit;
|
||||
}
|
||||
.activity-item:hover { background: var(--surface-strong); }
|
||||
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
||||
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
|
||||
|
||||
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
||||
|
||||
.show-more-btn {
|
||||
display: block; width: 100%; margin-top: 20px;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||
}
|
||||
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user