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:
tommy0103
2026-06-12 22:20:29 +08:00
parent b52f57b538
commit b524339d85
48 changed files with 16538 additions and 97 deletions
+440
View File
@@ -0,0 +1,440 @@
<script setup>
import { computed, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
IS_MAC,
FOLDER_SVG,
setRoute,
setView,
setProject,
setQuery,
setProjectSearch,
toggleSort,
toggleIncludeMessageBodies
} from './store.js';
import { formatProjectLabel } from './utils.js';
const router = useRouter();
const route = useRoute();
// --- Sidebar data ---
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
const totalMemoryCount = computed(() => state.memories.length);
const sessionCount = computed(() => state.sessions.length);
const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (state.route === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived;
});
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
if (state.projectSearch) {
const q = state.projectSearch.toLowerCase();
projects = projects.filter(p => p.toLowerCase().includes(q));
}
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
// Count per project
const counts = {};
for (const item of filtered) {
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
}
return projects.map(p => ({
slug: p,
label: formatProjectLabel(p),
count: counts[p] || 0
}));
});
// --- Toolbar visibility ---
const showToolbar = computed(() => {
const r = route.name;
return r === 'SessionList' || r === 'MemoryList';
});
const showSearchMsgsToggle = computed(() => {
return route.name === 'SessionList';
});
// --- Window title ---
const windowTitle = computed(() => {
const appName = 'Obelisk';
let scopeText = '';
if (route.name === 'Usage') {
scopeText = 'Usage';
} else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id);
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
} else {
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
scopeText = `Sessions${proj}`;
}
} else {
if (route.name === 'MemoryDetail') {
const m = state.memories.find(x => x.id === route.params.id);
scopeText = m ? `Memory · ${m.path.split('/').pop()}` : 'Memory';
} else {
const viewLabel = state.view === 'archived' ? 'Archived' : 'Active';
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
scopeText = `Memory · ${viewLabel}${proj}`;
}
}
return { appName, scopeText };
});
watch(() => windowTitle.value.scopeText, (scopeText) => {
document.title = `${windowTitle.value.appName}${scopeText}`;
}, { immediate: true });
// --- Navigation helpers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'usage') {
router.push('/usage');
} else {
router.push('/memory');
}
}
function handleSidebarView(view) {
setView(view);
router.push('/memory');
}
function handleSidebarProject(slug) {
setProject(slug);
// Stay on current list route
if (state.route === 'sessions') router.push('/sessions');
else router.push('/memory');
}
function handleProjectSearch(e) {
setProjectSearch(e.target.value);
}
// --- Search ---
let searchTimer = null;
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setQuery(value);
}, 200);
}
function handleToggleSort() {
toggleSort();
}
function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
// --- Keep-alive includes ---
const keepAliveIncludes = ['SessionDetail'];
</script>
<template>
<div class="app-shell">
<!-- Titlebar (macOS traffic-light region) -->
<div class="titlebar" :class="{ mac: IS_MAC }">
<div class="titlebar-drag"></div>
<div id="titlebar-text" class="titlebar-text">
<span class="app-name">{{ windowTitle.appName }}</span>
<span class="sep"></span>
<span class="scope">{{ windowTitle.scopeText }}</span>
</div>
</div>
<!-- Columns: sidebar + main -->
<div class="columns">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
<defs><linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#a78bfa"/><stop offset="1" stop-color="#6366f1"/></linearGradient></defs>
</svg>
<span class="name">Obelisk</span>
</div>
<!-- Navigation section -->
<div class="sidebar-section">
<button
class="sidebar-item"
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
@click="handleSidebarRoute('sessions')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 1v4M11 1v4"/></svg>
</span>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.5"/><path d="M8 5v3l2 1.5"/></svg>
</span>
<span class="label">Active</span>
<span class="badge">{{ activeCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
@click="handleSidebarView('archived')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/><path d="M1.5 3.5h13v2h-13z"/><path d="M6 8h4"/></svg>
</span>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
<button
class="sidebar-item"
:class="{ active: state.route === 'usage' }"
@click="handleSidebarRoute('usage')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/></svg>
</span>
<span class="label">Usage</span>
</button>
</div>
<!-- Projects section -->
<div class="sidebar-section projects">
<div class="sidebar-section-title">
<span>Projects</span>
</div>
<div class="sidebar-search">
<input
type="text"
placeholder="Filter..."
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div id="sidebar-projects" class="sidebar-projects-list">
<button
v-for="p in sidebarProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)"
>
<span class="icon" v-html="FOLDER_SVG"></span>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<div v-if="!sidebarProjects.length" class="sidebar-empty">
No projects
</div>
</div>
</div>
</aside>
<!-- Main content area -->
<div class="main">
<!-- Toolbar with search + sort (only on list views) -->
<div v-if="showToolbar" class="toolbar">
<div class="breadcrumb">
<span class="crumb terminal">
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
</span>
<template v-if="state.projectFilter !== 'all'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
</template>
</div>
<div class="spacer"></div>
<div id="search-wrap" class="search-wrap">
<input
id="search"
type="text"
class="search-input"
placeholder="Search..."
@input="handleSearch"
/>
<button
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
Msgs
</button>
</div>
<button
id="sort-toggle"
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
>
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
</button>
</div>
<!-- Toolbar for detail views (breadcrumb only) -->
<div v-if="!showToolbar" class="toolbar">
<div class="breadcrumb">
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
Sessions
</router-link>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
</router-link>
</template>
<template v-if="route.name === 'SessionDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
</span>
</template>
<template v-if="route.name === 'SubagentDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ route.params.agentId }}</span>
</template>
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
Memory
</router-link>
<template v-if="route.name === 'MemoryDetail'">
<span class="crumb-sep">/</span>
<span class="crumb terminal filename">
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
</span>
</template>
<span v-if="route.name === 'Usage'" class="crumb terminal">Usage</span>
</div>
</div>
<!-- Router view with keep-alive for SessionDetail -->
<router-view v-slot="{ Component }">
<keep-alive :include="keepAliveIncludes">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
<!-- Status bar -->
<div class="statusbar">
<div id="status-left" class="status-left"></div>
<div id="status-right" class="status-right"></div>
</div>
</div>
</template>
<style>
@import '../styles/base.css';
@import '../styles/sidebar.css';
@import '../styles/toolbar.css';
@import '../styles/list.css';
@import '../styles/detail.css';
@import '../styles/statusbar.css';
</style>
<style scoped>
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.titlebar {
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
-webkit-app-region: drag;
background: var(--bg-2);
border-bottom: 1px solid var(--hairline);
}
.titlebar.mac {
padding-left: 78px;
}
.titlebar-drag {
position: absolute;
inset: 0;
}
.titlebar-text {
font-size: 12px;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
}
.titlebar-text .app-name {
font-weight: 600;
color: var(--fg-2);
}
.titlebar-text .sep {
opacity: 0.4;
}
.columns {
display: flex;
flex: 1;
min-height: 0;
}
.spacer {
flex: 1;
}
.statusbar {
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: var(--bg-2);
border-top: 1px solid var(--hairline);
font-size: 11px;
color: var(--muted);
}
.sidebar-empty {
padding: 8px 10px;
font-size: 11px;
color: var(--muted-2);
}
.sidebar-projects-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
</style>
+308
View File
@@ -0,0 +1,308 @@
<script setup>
import { computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
state,
FOLDER_SVG,
setRoute,
setView,
setProject,
setProjectSearch
} from '../store.js';
import { formatProjectLabel } from '../utils.js';
const router = useRouter();
const route = useRoute();
// --- Counts ---
const sessionCount = computed(() => state.sessions.length);
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
const totalMemoryCount = computed(() => state.memories.length);
// --- Projects list ---
const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (state.route === 'sessions') return true;
return state.view === 'archived' ? item.archived : !item.archived;
});
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
if (state.projectSearch) {
const q = state.projectSearch.toLowerCase();
projects = projects.filter(p => p.toLowerCase().includes(q));
}
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
// Count per project
const counts = {};
for (const item of filtered) {
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
}
return projects.map(p => ({
slug: p,
label: formatProjectLabel(p),
count: counts[p] || 0
}));
});
// --- Active state helpers ---
function isSessionsActive() {
return state.route === 'sessions' && state.projectFilter === 'all';
}
function isMemoryViewActive(view) {
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
}
function isUsageActive() {
return state.route === 'usage';
}
function isProjectActive(slug) {
return state.projectFilter === slug;
}
// --- Navigation handlers ---
function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'usage') {
router.push('/usage');
} else {
router.push('/memory');
}
}
function handleSidebarView(view) {
setView(view);
router.push('/memory');
}
function handleSidebarProject(slug) {
setProject(slug);
if (state.route === 'sessions') router.push('/sessions');
else router.push('/memory');
}
function handleProjectSearch(e) {
setProjectSearch(e.target.value);
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-brand">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
<defs>
<linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#a78bfa"/>
<stop offset="1" stop-color="#6366f1"/>
</linearGradient>
</defs>
</svg>
<span class="name">Obelisk</span>
</div>
<!-- Library section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<button
class="sidebar-item"
:class="{ active: isSessionsActive() }"
@click="handleSidebarRoute('sessions')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M5 1v4M11 1v4"/>
</svg>
</span>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<!-- Memory parent (non-clickable label) -->
<div class="sidebar-section-title" style="padding-top: 8px;">
<span>Memory</span>
<span class="badge">{{ totalMemoryCount }}</span>
</div>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('active') }"
@click="handleSidebarView('active')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="8" cy="8" r="5.5"/>
<path d="M8 5v3l2 1.5"/>
</svg>
</span>
<span class="label">Active</span>
<span class="badge">{{ activeCount }}</span>
</button>
<button
class="sidebar-item sub"
:class="{ active: isMemoryViewActive('archived') }"
@click="handleSidebarView('archived')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/>
<path d="M1.5 3.5h13v2h-13z"/>
<path d="M6 8h4"/>
</svg>
</span>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
</div>
<!-- Stats section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button
class="sidebar-item"
:class="{ active: isUsageActive() }"
@click="handleSidebarRoute('usage')"
>
<span class="icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
</svg>
</span>
<span class="label">Usage</span>
</button>
</div>
<!-- Projects section -->
<div class="sidebar-section projects">
<div class="sidebar-section-title">
<span>Projects</span>
</div>
<div class="sidebar-search">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="7" cy="7" r="4.5"/>
<path d="M10.5 10.5L14 14"/>
</svg>
<input
type="text"
placeholder="Filter..."
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div class="sidebar-list">
<button
v-for="p in sidebarProjects"
:key="p.slug"
class="sidebar-item"
:class="{ active: isProjectActive(p.slug) }"
@click="handleSidebarProject(p.slug)"
>
<span class="icon" v-html="FOLDER_SVG"></span>
<span class="label">{{ p.label }}</span>
<span class="badge">{{ p.count }}</span>
</button>
<div v-if="!sidebarProjects.length" class="sidebar-empty">
No projects
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
border-right: 1px solid var(--hairline-strong);
background: rgba(0,0,0,0.2);
display: flex; flex-direction: column;
min-height: 0;
}
.sidebar-brand {
display: flex; align-items: center; gap: 8px;
padding: 0 14px; height: 36px;
border-bottom: 1px solid var(--hairline);
flex-shrink: 0;
}
.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); }
.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); }
.sidebar-section { padding: 8px 6px; flex-shrink: 0; }
.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; }
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
.sidebar-section-title {
padding: 4px 10px 6px;
font-size: 10.5px; color: var(--muted);
font-weight: 500; letter-spacing: 0.04em;
display: flex; justify-content: space-between;
flex-shrink: 0;
}
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
.sidebar-search input {
width: 100%; height: 24px;
padding: 0 8px 0 24px;
border: 1px solid var(--hairline); border-radius: 4px;
background: var(--surface);
font-size: var(--text-sm); color: var(--fg);
transition: all 0.1s;
}
.sidebar-search input::placeholder { color: var(--muted-2); }
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
.sidebar-search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
width: 11px; height: 11px; color: var(--muted); pointer-events: none;
}
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
.sidebar-item {
display: flex; align-items: center; gap: 8px;
padding: 0 10px; height: var(--row-h-compact);
border-radius: 5px;
color: var(--fg-2); font-size: var(--text-base);
cursor: pointer; user-select: none;
transition: background 0.08s; position: relative;
width: 100%; text-align: left;
border: none; background: none;
}
.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); }
.sidebar-item.active { background: var(--accent-soft); color: var(--fg); }
.sidebar-item.active::before {
content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px;
width: 2px; background: var(--accent); border-radius: 1px;
box-shadow: 0 0 8px var(--accent-glow);
}
.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; }
.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); }
.sidebar-item.warning .icon { color: var(--danger); }
.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-item .badge {
font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); font-variant-numeric: tabular-nums;
line-height: 1; min-width: 22px; text-align: right;
flex-shrink: 0; padding: 2px 0;
}
.sidebar-item.active .badge { color: var(--fg-2); }
.sidebar-item.warning .badge {
color: var(--danger); background: var(--danger-soft);
padding: 2px 6px; border-radius: 8px;
margin-right: -6px; min-width: 22px;
}
.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); }
.sidebar-item.sub .icon { width: 12px; height: 12px; }
.sidebar-empty {
padding: 8px 10px;
font-size: 11px;
color: var(--muted-2);
}
</style>
+379
View File
@@ -0,0 +1,379 @@
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { state, toggleSort, setQuery, toggleIncludeMessageBodies } from '../store.js';
import { formatProjectLabel } from '../utils.js';
// --- Route info ---
const route = useRoute();
const isListView = computed(() => {
return route.name === 'SessionList' || route.name === 'MemoryList';
});
const showSearchMsgsToggle = computed(() => {
return route.name === 'SessionList';
});
// --- Breadcrumb computation ---
const breadcrumbs = computed(() => {
const name = route.name;
const crumbs = [];
if (name === 'SessionList') {
crumbs.push({ label: 'Sessions', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'SessionDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: s?.title || route.params.id, terminal: true });
} else if (name === 'SubagentDetail') {
crumbs.push({ label: 'Sessions', to: '/sessions' });
const s = state.sessions.find(x => x.id === route.params.id);
crumbs.push({ label: (s?.title || '').slice(0, 30) || route.params.id, to: `/sessions/${route.params.id}` });
crumbs.push({ label: route.params.agentId, terminal: true });
} else if (name === 'MemoryList') {
crumbs.push({ label: 'Memory', terminal: true });
if (state.projectFilter !== 'all') {
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
}
} else if (name === 'MemoryDetail') {
crumbs.push({ label: 'Memory', to: '/memory' });
const m = state.memories.find(x => x.id === route.params.id);
const filename = (m?.path || '').split('/').pop();
crumbs.push({ label: filename, terminal: true, filename: true });
} else if (name === 'Usage') {
crumbs.push({ label: 'Usage', terminal: true });
}
return crumbs;
});
// --- Search ---
const searchInput = ref(null);
let searchTimer = null;
function handleSearch(e) {
const value = e.target.value;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
setQuery(value);
}, 200);
}
// --- Keyboard shortcut: / to focus search ---
function handleKeydown(e) {
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
searchInput.value?.focus();
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(searchTimer);
});
// --- Sort ---
function handleToggleSort() {
toggleSort();
}
function handleToggleSearchMsgs() {
toggleIncludeMessageBodies();
}
</script>
<template>
<div class="toolbar">
<div class="breadcrumb">
<template v-for="(crumb, i) in breadcrumbs" :key="i">
<span v-if="i > 0" class="crumb-sep">/</span>
<router-link
v-if="crumb.to"
class="crumb"
:to="crumb.to"
>
{{ crumb.label }}
</router-link>
<span
v-else
class="crumb terminal"
:class="{ filename: crumb.filename }"
>
{{ crumb.label }}
</span>
</template>
</div>
<div class="toolbar-spacer"></div>
<!-- Search + sort controls (list views only) -->
<template v-if="isListView">
<div class="toolbar-search">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="6.5" cy="6.5" r="4"/>
<path d="M10 10l3.5 3.5"/>
</svg>
<input
ref="searchInput"
type="text"
placeholder="Search..."
@input="handleSearch"
/>
<span class="toolbar-search-kbd">/</span>
</div>
<button
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M2 5.5l6 3.5 6-3.5"/>
</svg>
</button>
<button
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
>
<span class="label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
<path class="arrow-up" d="M8 3v5M5.5 5.5L8 3l2.5 2.5"/>
<path class="arrow-down" d="M8 8v5M5.5 10.5L8 13l2.5-2.5"/>
</svg>
</button>
</template>
</div>
</template>
<style scoped>
.toolbar {
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
border-bottom: 1px solid var(--hairline-strong);
background: rgba(0, 0, 0, 0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.crumb {
font-size: var(--text-md);
color: var(--muted);
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
transition: all 0.1s;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
border: 0;
background: transparent;
white-space: nowrap;
text-decoration: none;
}
.crumb:hover {
background: var(--surface-strong);
color: var(--fg-2);
}
.crumb.terminal {
color: var(--fg);
font-weight: 600;
cursor: default;
}
.crumb.terminal:hover {
background: transparent;
}
.crumb svg {
width: 13px;
height: 13px;
color: var(--muted);
}
.crumb.filename {
font-family: var(--font-mono);
font-weight: 500;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.crumb-sep {
color: var(--muted-2);
font-size: var(--text-md);
user-select: none;
}
.toolbar-spacer {
flex: 1;
}
.toolbar-search {
width: 220px;
position: relative;
}
.toolbar-search input {
width: 100%;
height: 26px;
padding: 0 30px 0 26px;
border: 1px solid var(--hairline);
border-radius: 5px;
background: var(--surface);
font-size: var(--text-base);
color: var(--fg);
transition: all 0.12s;
}
.toolbar-search input::placeholder {
color: var(--muted-2);
}
.toolbar-search input:focus {
outline: 0;
border-color: var(--accent);
background: var(--surface-strong);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.toolbar-search-icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
color: var(--muted);
pointer-events: none;
}
.toolbar-search-kbd {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted-2);
padding: 1px 5px;
border: 1px solid var(--hairline);
border-radius: 3px;
pointer-events: none;
line-height: 1.2;
}
.toolbar-search input:focus ~ .toolbar-search-kbd,
.toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd {
opacity: 0;
}
.filter-toggle {
height: 26px;
width: 26px;
border-radius: 5px;
color: var(--muted);
display: inline-grid;
place-items: center;
transition: all 0.1s;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.filter-toggle:hover {
color: var(--fg-2);
background: var(--surface-strong);
}
.filter-toggle.active {
color: var(--accent-2);
background: var(--accent-soft);
border-color: var(--accent-soft);
}
.filter-toggle svg {
width: 13px;
height: 13px;
}
.sort-group {
display: inline-flex;
align-items: center;
gap: 2px;
height: 26px;
padding: 0 4px 0 8px;
border-radius: 5px;
cursor: pointer;
color: var(--muted);
font-size: var(--text-sm);
transition: background 0.1s, color 0.1s;
border: none;
background: transparent;
}
.sort-group:hover {
background: var(--surface-strong);
color: var(--fg-2);
}
.sort-group .label {
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
.sort-group svg {
width: 13px;
height: 13px;
}
.sort-group .arrow-up,
.sort-group .arrow-down {
transition: opacity 0.12s;
}
.sort-group.desc .arrow-up {
opacity: 0.25;
}
.sort-group.desc .arrow-down {
opacity: 1;
}
.sort-group.asc .arrow-up {
opacity: 1;
}
.sort-group.asc .arrow-down {
opacity: 0.25;
}
</style>
+376
View File
@@ -0,0 +1,376 @@
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to reactive store.
// All DB access goes through this module.
import { markRaw } from 'vue';
import { state, clearUndo } from './store.js';
/**
* Load initial data from the DB and populate state.memories, state.sessions,
* and state.projects.
*/
export async function loadInitialData() {
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
window.obelisk.getMemories(),
window.obelisk.getSessions(),
window.obelisk.getStats(),
window.obelisk.getProjects()
]);
// Transform memories: DB records -> render-layer shape
state.memories = (rawMemories || []).map(m => ({
...m,
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
archived: !!m.deleted_at,
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
health: 'ok',
anchors: [],
markdown: null // loaded on demand via loadMemoryMarkdown
}));
// Sessions: keep DB shape, add empty messages array for on-demand loading
state.sessions = (rawSessions || []).map(s => ({
...s,
messages: []
}));
state.projects = projects || [];
state.stats = stats || {};
}
/**
* Load full detail for a session: messages with inline tool_calls (each with
* result), summaries, subagents, and workflow data.
*
* Returns the assembled session object (also updates state.sessions entry).
*/
export async function loadSessionDetail(sessionId) {
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
await Promise.all([
window.obelisk.getSessionMessages(sessionId),
window.obelisk.getSessionToolCalls(sessionId),
window.obelisk.getSessionToolResults(sessionId),
window.obelisk.getSessionSubagents(sessionId),
window.obelisk.getSessionWorkflows(sessionId),
window.obelisk.getSessionSummaries(sessionId)
]);
// Index tool results by tool_use_id for fast lookup
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
// Index subagents by parent_tool_use_id
const subagentsByCallId = {};
for (const sa of (subagents || [])) {
if (sa.parent_tool_use_id) {
subagentsByCallId[sa.parent_tool_use_id] = sa;
}
}
// Group tool_calls by message_uuid, attaching result and subagent inline
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
// Attach subagent data if present
const sa = subagentsByCallId[tc.id];
if (sa) {
call.subagent = {
agent_id: sa.agent_id,
agent_type: sa.agent_type,
description: sa.description
};
}
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
// Attach workflow data to Workflow tool calls
for (const wf of (workflows || [])) {
for (const calls of Object.values(callsByMessageUuid)) {
for (const call of calls) {
if (call.name === 'Workflow' && !call.workflow) {
const resultText = call.result?.content || '';
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
call.workflow = {
run_id: wf.run_id,
workflow_name: wf.workflow_name,
status: wf.status,
duration_ms: wf.duration_ms,
total_tokens: wf.total_tokens,
agent_count: wf.agent_count,
agents: (wf.agents || []).map(a => ({
agent_id: a.agent_id,
phase: a.phase,
label: a.label,
state: a.state,
tokens: a.tokens,
duration_ms: a.duration_ms,
}))
};
}
}
}
}
}
// Index summaries by session
const sessionSummaries = (summaries || []).map(s => ({
source: s.source,
content: s.content,
timestamp: s.timestamp
}));
// Assemble messages with tool_calls inline
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) {
assembled.tool_calls = calls;
}
return assembled;
});
// Merge adjacent assistant messages:
// - tool_result user messages are skipped (results shown inside tool_call panels)
// - consecutive tool_use messages (separated by tool_results) merge into one
// - thinking messages merge into the next non-thinking assistant message
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
// Skip tool_result user messages
if (msg.content_type === 'tool_result') continue;
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
// Attach workflow data if present
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
// Build assembled session object
const session = state.sessions.find(s => s.id === sessionId);
const assembled = {
...(session || {}),
id: sessionId,
messages: assembledMessages
};
if (workflow) {
assembled.workflow = workflow;
}
// Update in-place in state.sessions
const idx = state.sessions.findIndex(s => s.id === sessionId);
if (idx !== -1) {
state.sessions[idx] = assembled;
}
return assembled;
}
/**
* Load full detail for a subagent conversation.
* Returns assembled messages with tool_calls inline.
*/
export async function loadSubagentDetail(agentId) {
const [messages, toolCalls, toolResults] = await Promise.all([
window.obelisk.getSubagentMessages(agentId),
window.obelisk.getSubagentToolCalls(agentId),
window.obelisk.getSubagentToolResults(agentId),
]);
const resultsByCallId = {};
for (const r of (toolResults || [])) {
resultsByCallId[r.tool_use_id] = r;
}
const callsByMessageUuid = {};
for (const tc of (toolCalls || [])) {
const call = {
id: tc.id,
name: tc.name,
input_json: tc.input_json,
result: resultsByCallId[tc.id] || null
};
const msgUuid = tc.message_uuid;
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
callsByMessageUuid[msgUuid].push(call);
}
const rawAssembled = (messages || []).map(msg => {
const assembled = {
uuid: msg.uuid,
type: msg.type || msg.role,
timestamp: msg.timestamp,
text: msg.text,
content_type: msg.content_type || null,
is_meta: msg.is_meta || 0
};
const calls = callsByMessageUuid[msg.uuid];
if (calls && calls.length > 0) assembled.tool_calls = calls;
return assembled;
});
// Same merging logic as session detail
const assembledMessages = [];
for (let i = 0; i < rawAssembled.length; i++) {
const msg = rawAssembled[i];
if (msg.content_type === 'tool_result') continue;
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
const thinkingParts = [msg.text || ''];
let j = i + 1;
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
thinkingParts.push(rawAssembled[j].text || '');
j++;
}
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
i = j - 1;
continue;
}
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
i = j - 1;
continue;
}
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
if (msg._thinking) merged._thinking = msg._thinking;
let j = i + 1;
while (j < rawAssembled.length) {
const next = rawAssembled[j];
if (next.content_type === 'tool_result') { j++; continue; }
if (next.type === 'assistant' && next.content_type === 'tool_use') {
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
if (next.text && !merged.text) merged.text = next.text;
j++;
continue;
}
break;
}
assembledMessages.push(merged);
i = j - 1;
} else {
const out = { ...msg };
if (msg._thinking) out._thinking = msg._thinking;
assembledMessages.push(out);
}
}
return assembledMessages;
}
const TEXT_LIMIT = 10000;
/**
* Check if a message text was truncated during indexing.
*/
export function isTextTruncated(text) {
return text && text.length >= TEXT_LIMIT;
}
/**
* Fetch the full untruncated text for a message from its source JSONL.
* Returns the full text string or null.
*/
export async function loadFullText(uuid) {
try {
return await window.obelisk.getMessageFullText(uuid);
} catch {
return null;
}
}
/**
* Load the markdown content of a memory file.
* Returns the content string or null on failure.
*/
export async function loadMemoryMarkdown(memoryPath) {
try {
const content = await window.obelisk.readMemoryFile(memoryPath);
return content || null;
} catch {
return null;
}
}
/**
* Archive a memory by id. Updates state after successful IPC call.
*/
export async function archiveMemory(id) {
await window.obelisk.archiveMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = true;
mem.archivedAt = Date.now();
}
}
/**
* Restore an archived memory by id. Updates state after successful IPC call.
*/
export async function restoreMemory(id) {
await window.obelisk.restoreMemory(id);
const mem = state.memories.find(m => m.id === id);
if (mem) {
mem.archived = false;
mem.archivedAt = null;
}
}
+25
View File
@@ -0,0 +1,25 @@
// Vue 3 application entry point for Obelisk.
import { createApp } from 'vue';
import App from './App.vue';
import router from './router.js';
import { loadInitialData } from './data.js';
// Import all original CSS globally
import '../styles/base.css';
import '../styles/sidebar.css';
import '../styles/toolbar.css';
import '../styles/list.css';
import '../styles/detail.css';
import '../styles/statusbar.css';
const app = createApp(App);
app.use(router);
// Load data before the first render completes
router.isReady().then(() => {
loadInitialData();
});
app.mount('#app');
+65
View File
@@ -0,0 +1,65 @@
// Vue Router configuration for Obelisk.
// Routes map to the main content views; sidebar navigation drives route changes.
import { createRouter, createWebHashHistory } from 'vue-router';
// Lazy-loaded view components (will be created as Vue SFCs later)
const SessionList = () => import('./views/SessionList.vue');
const SessionDetail = () => import('./views/SessionDetail.vue');
const SubagentDetail = () => import('./views/SubagentDetail.vue');
const MemoryList = () => import('./views/MemoryList.vue');
const MemoryDetail = () => import('./views/MemoryDetail.vue');
const Usage = () => import('./views/Usage.vue');
const routes = [
{
path: '/sessions',
name: 'SessionList',
component: SessionList
},
{
path: '/sessions/:id',
name: 'SessionDetail',
component: SessionDetail,
props: true,
meta: { keepAlive: true }
},
{
path: '/sessions/:id/agent/:agentId',
name: 'SubagentDetail',
component: SubagentDetail,
props: true
},
{
path: '/memory',
name: 'MemoryList',
component: MemoryList
},
{
path: '/memory/:id',
name: 'MemoryDetail',
component: MemoryDetail,
props: true
},
{
path: '/usage',
name: 'Usage',
component: Usage
},
{
path: '/',
redirect: '/memory'
},
{
// Catch-all redirect
path: '/:pathMatch(.*)*',
redirect: '/memory'
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
export default router;
+128
View File
@@ -0,0 +1,128 @@
// Reactive store -- Vue 3 reactive() replaces the plain object from state.js.
// All state fields are ported; action functions mutate the reactive state.
import { reactive, markRaw } from 'vue';
export const state = reactive({
memories: [],
sessions: [],
projects: [],
stats: {},
route: 'memory',
view: 'active', // 'active' | 'archived'
mode: 'list', // 'list' | 'detail'
detailId: null,
subagentId: null,
subagentDescription: null,
pendingFocusUuid: null,
query: '',
projectFilter: 'all',
projectSearch: '',
sortDesc: true,
includeMessageBodies: false,
cursorId: null,
selection: markRaw(new Set()),
showSource: false,
lastArchiveSnapshot: null,
undoTimer: null,
undoExpires: 0
});
// Platform detection
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
// SVG icon constants
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
// --- Action functions ---
export function setRoute(route) {
state.route = route;
state.mode = 'list';
state.detailId = null;
state.cursorId = null;
state.selection = markRaw(new Set());
state.query = '';
}
export function setView(v) {
state.route = 'memory';
state.view = v;
state.mode = 'list';
state.detailId = null;
state.cursorId = null;
state.selection = markRaw(new Set());
state.projectFilter = 'all';
}
export function setProject(p) {
state.projectFilter = p;
state.cursorId = null;
state.selection = markRaw(new Set());
state.mode = 'list';
state.detailId = null;
}
export function toggleSort() {
state.sortDesc = !state.sortDesc;
}
export function enterDetail(id) {
state.detailId = id;
state.mode = 'detail';
state.showSource = false;
}
export function exitDetail() {
if (state.subagentId) {
state.subagentId = null;
state.subagentDescription = null;
return;
}
state.mode = 'list';
state.detailId = null;
state.pendingFocusUuid = null;
}
export function navigateToSession(sessionId, focusUuid) {
state.route = 'sessions';
state.mode = 'detail';
state.detailId = sessionId;
state.subagentId = null;
state.subagentDescription = null;
state.pendingFocusUuid = focusUuid || null;
state.query = '';
}
export function navigateToSubagent(agentId, description) {
state.subagentId = agentId;
state.subagentDescription = description || agentId;
}
export function setCursor(id, opts = {}) {
state.cursorId = id;
if (!opts.keepSelection) {
state.selection = markRaw(new Set());
}
}
export function setQuery(q) {
state.query = q;
}
export function setProjectSearch(q) {
state.projectSearch = q;
}
export function toggleIncludeMessageBodies() {
state.includeMessageBodies = !state.includeMessageBodies;
}
export function clearUndo() {
state.lastArchiveSnapshot = null;
if (state.undoTimer) {
clearInterval(state.undoTimer);
state.undoTimer = null;
}
}
+163
View File
@@ -0,0 +1,163 @@
// Utility functions ported from the vanilla JS utils.js.
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
import { state } from './store.js';
// --- Time / formatting ---
export function pad2(n) { return String(n).padStart(2, '0'); }
export function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
export function fmtListTime(ts) {
const d = new Date(ts);
const now = new Date();
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
if (isSameDay(d, now)) return hhmm;
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
}
export function fmtRelative(ts) {
const diff = Date.now() - ts;
const min = 60000, hr = 3600000, day = 86400000;
if (diff < 0) return 'in the future';
if (diff < min) return 'just now';
if (diff < hr) return Math.floor(diff / min) + 'm ago';
if (diff < day) return Math.floor(diff / hr) + 'h ago';
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
return Math.floor(diff / (day * 365)) + 'y ago';
}
export function fmtClockTime(iso) {
const d = new Date(iso);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
export function fmtSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
return (bytes / 1024 / 1024).toFixed(1) + 'M';
}
// --- HTML / Markdown ---
export function escapeHTML(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function highlightPlain(text, query) {
if (!query) return escapeHTML(text);
const safe = escapeHTML(text);
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return safe.replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`);
}
export function sanitizeMarkdown(html) {
return html
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '');
}
export function highlightTextNodes(rootEl, query) {
if (!query) return;
const q = query.toLowerCase();
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const text = node.nodeValue;
if (!text) continue;
const lower = text.toLowerCase();
if (!lower.includes(q)) continue;
const frag = document.createDocumentFragment();
let last = 0, i = lower.indexOf(q);
while (i !== -1) {
if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i)));
const mark = document.createElement('mark');
mark.textContent = text.slice(i, i + q.length);
frag.appendChild(mark);
last = i + q.length;
i = lower.indexOf(q, last);
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
}
export function renderMarkdown(text, opts = {}) {
if (text == null) return '';
// marked is loaded globally via CDN in index.html
const html = sanitizeMarkdown(window.marked.parse(text));
const cls = opts.variant === 'msg' ? 'markdown-msg'
: opts.variant === 'compact' ? 'markdown-compact'
: 'markdown-body';
const container = document.createElement('div');
container.className = cls;
container.innerHTML = html;
if (opts.query) highlightTextNodes(container, opts.query.trim());
return container.outerHTML;
}
// --- Duration / tokens / tooltip ---
export function fmtDuration(ms) {
if (!ms) return '—';
const s = Math.floor(ms / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const parts = [];
if (d) parts.push(`${d}d`);
if (h) parts.push(`${h}h`);
if (m) parts.push(`${m}m`);
if (sec || !parts.length) parts.push(`${sec}s`);
return parts.join(' ');
}
export function fmtTokens(n) {
if (!n) return '0';
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
return String(n);
}
export function fmtTooltipDate(isoDay) {
const d = new Date(isoDay + 'T00:00:00');
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const day = d.getDate();
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th';
const thisYear = new Date().getFullYear();
if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`;
return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`;
}
export function positionTooltip(el, x, y) {
const pad = 12;
const rect = el.getBoundingClientRect();
let left = x + pad;
if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad;
el.style.left = left + 'px';
el.style.top = (y - 28) + 'px';
}
// --- Project label ---
export function formatProjectLabel(slug) {
if (!slug) return '(no project)';
const session = state.sessions.find(s => s.project === slug && s.project_path);
if (session?.project_path) {
const parts = session.project_path.split('/');
return parts.slice(-2).join('/');
}
return slug.replace(/^-/, '');
}
+8
View File
@@ -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>
+736
View File
@@ -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>
+474
View File
@@ -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">&middot;</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">&#x2699;</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">&rarr;</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 &rarr;</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 &middot; {{ 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>
+188
View File
@@ -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>
+770
View File
@@ -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">&#9733;</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">&#8627;</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">&#9733;</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">&#8627;</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>