feat(app): embed indexer in Electron with file-watching service and UI refinements

Extract schema DDL into scripts/schema.sql shared between CLI and app.
  Add an in-process chokidar-based indexer-service that watches ~/.claude/projects
  for JSONL changes, debounces, and triggers background rebuilds via a worker
  thread. Rename Usage view to Activity, flesh out MemoryDetail and SubagentDetail
  views, and refine App.vue layout/routing. The main process now starts/stops the
  indexer lifecycle and notifies renderer windows on index updates.
This commit is contained in:
tommy0103
2026-06-13 03:42:01 +08:00
parent b524339d85
commit 4eec6b38c9
29 changed files with 1370 additions and 314 deletions
+162 -206
View File
@@ -25,16 +25,23 @@ const archivedCount = computed(() => state.memories.filter(m => m.archived).leng
const totalMemoryCount = computed(() => state.memories.length);
const sessionCount = computed(() => state.sessions.length);
const currentRouteType = computed(() => {
const name = route.name;
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
if (name === 'Activity') return 'activity';
return 'memory';
});
const sidebarProjects = computed(() => {
const items = state.route === 'sessions' ? state.sessions : state.memories;
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
const filtered = items.filter(item => {
if (state.route === 'sessions') return true;
if (currentRouteType.value === '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 = projects.filter(p => formatProjectLabel(p).toLowerCase().includes(q));
}
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
@@ -51,6 +58,11 @@ const sidebarProjects = computed(() => {
}));
});
const totalProjectCount = computed(() => {
const items = currentRouteType.value === 'sessions' ? state.sessions : state.memories;
return new Set(items.map(i => i.project).filter(Boolean)).size;
});
// --- Toolbar visibility ---
const showToolbar = computed(() => {
@@ -67,8 +79,8 @@ const showSearchMsgsToggle = computed(() => {
const windowTitle = computed(() => {
const appName = 'Obelisk';
let scopeText = '';
if (route.name === 'Usage') {
scopeText = 'Usage';
if (route.name === 'Activity') {
scopeText = 'Activity';
} else if (route.name?.startsWith('Session')) {
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
const s = state.sessions.find(x => x.id === route.params.id);
@@ -100,8 +112,8 @@ function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'usage') {
router.push('/usage');
} else if (routeName === 'activity') {
router.push('/activity');
} else {
router.push('/memory');
}
@@ -112,6 +124,10 @@ function handleSidebarView(view) {
router.push('/memory');
}
function handleClearProject() {
setProject('all');
}
function handleSidebarProject(slug) {
setProject(slug);
// Stay on current list route
@@ -147,94 +163,125 @@ 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">
<div class="app">
<div class="titlebar">
<div class="titlebar-text" id="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 viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<radialGradient id="icon-aurora" cx="50%" cy="62%" r="55%">
<stop offset="0%" stop-color="#ec4899" stop-opacity="0.8"/>
<stop offset="45%" stop-color="#a855f7" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="icon-stone-lit" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#cbd5e1"/>
<stop offset="100%" stop-color="#475569"/>
</linearGradient>
</defs>
<ellipse cx="20" cy="22" rx="15" ry="11" fill="url(#icon-aurora)"/>
<ellipse cx="20" cy="21" rx="9" ry="7" fill="url(#icon-aurora)" opacity="0.7"/>
<circle cx="8" cy="13" r="0.7" fill="#fff" opacity="0.9"/>
<circle cx="32" cy="11" r="0.9" fill="#fff" opacity="0.95"/>
<circle cx="34" cy="22" r="0.5" fill="#fff" opacity="0.7"/>
<polygon points="20,7 16.5,12 23.5,12" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 16.5,12 17.5,33 20,33" fill="url(#icon-stone-lit)"/>
<polygon points="20,12 23.5,12 22.5,33 20,33" fill="#1e293b"/>
<rect x="15.5" y="33" width="9" height="1.6" rx="0.3" fill="#0f172a"/>
</svg>
<span class="name">Obelisk</span>
</div>
<!-- Navigation section -->
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Library</span></div>
<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>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z" stroke-linejoin="round"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span class="label">Sessions</span>
<span class="badge">{{ sessionCount }}</span>
</button>
<button
class="sidebar-item"
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
@click="handleSidebarView('active')"
>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2.5" y="2.5" width="11" height="11" rx="2"/>
<path d="M5 8h6M5 5.5h6M5 10.5h4" stroke-linecap="round"/>
</svg>
<span class="label">Memory</span>
<span class="badge">{{ totalMemoryCount }}</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>
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2" fill="currentColor"/>
</svg>
<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>
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="6" cy="6" r="2"/>
</svg>
<span class="label">Archived</span>
<span class="badge">{{ archivedCount }}</span>
</button>
</div>
<div class="sidebar-section">
<div class="sidebar-section-title"><span>Stats</span></div>
<button
class="sidebar-item"
:class="{ active: state.route === 'usage' }"
@click="handleSidebarRoute('usage')"
:class="{ active: route.name === 'Activity' }"
@click="handleSidebarRoute('activity')"
>
<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>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="10" width="2.5" height="4"/>
<rect x="6" y="6" width="2.5" height="8"/>
<rect x="10" y="3" width="2.5" height="11"/>
</svg>
<span class="label">Activity</span>
</button>
</div>
<!-- Projects section -->
<div class="sidebar-section projects">
<div class="sidebar-section-title">
<span>Projects</span>
</div>
<div class="sidebar-search">
<div class="sidebar-section-title"><span>Projects</span></div>
<div class="sidebar-search" v-if="totalProjectCount >= 6">
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input
type="text"
placeholder="Filter..."
placeholder="Filter projects…"
autocomplete="off"
:value="state.projectSearch"
@input="handleProjectSearch"
/>
</div>
<div id="sidebar-projects" class="sidebar-projects-list">
<div class="sidebar-list" id="sidebar-projects">
<button
v-for="p in sidebarProjects"
:key="p.slug"
@@ -242,199 +289,108 @@ const keepAliveIncludes = ['SessionDetail'];
:class="{ active: state.projectFilter === p.slug }"
@click="handleSidebarProject(p.slug)"
>
<span class="icon" v-html="FOLDER_SVG"></span>
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2 5.5V12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 14 12V6.5A1.5 1.5 0 0 0 12.5 5H8.3L7 3.5H3.5A1.5 1.5 0 0 0 2 5z"/>
</svg>
<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>
<main class="main">
<div class="toolbar">
<div class="breadcrumb" id="breadcrumb">
<template v-if="showToolbar">
<template v-if="state.projectFilter !== 'all'">
<button class="crumb" @click="handleClearProject">
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
</button>
<span class="crumb-sep">/</span>
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
</template>
<template v-else>
<span class="crumb terminal">
{{ state.route === 'sessions' ? 'Sessions' : state.route === 'memory' ? 'Memory' : 'Activity' }}
</span>
</template>
</template>
<template v-else>
<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 === 'Activity'" class="crumb terminal">Activity</span>
</template>
</div>
<div class="spacer"></div>
<div id="search-wrap" class="search-wrap">
<div class="toolbar-spacer"></div>
<div class="toolbar-search" id="search-wrap" v-if="showToolbar">
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="7" cy="7" r="5"/>
<path d="M11 11l3 3" stroke-linecap="round"/>
</svg>
<input
id="search"
type="text"
class="search-input"
placeholder="Search..."
placeholder="Search"
autocomplete="off"
:value="state.query"
@input="handleSearch"
/>
<button
v-if="showSearchMsgsToggle"
class="filter-toggle"
:class="{ active: state.includeMessageBodies }"
@click="handleToggleSearchMsgs"
title="Include message bodies in search"
>
Msgs
</button>
<span class="toolbar-search-kbd">/</span>
</div>
<button
id="sort-toggle"
v-if="showToolbar"
class="sort-group"
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
@click="handleToggleSort"
id="sort-toggle"
title="Toggle sort (S)"
>
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<span class="label" id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path class="arrow-up" d="M5 6l3-3 3 3"/>
<path class="arrow-down" d="M5 10l3 3 3-3"/>
</svg>
</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">
<keep-alive :include="['SessionDetail']">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</main>
</div>
<!-- Status bar -->
<div class="statusbar">
<div id="status-left" class="status-left"></div>
<div id="status-right" class="status-right"></div>
<div class="status-left" id="status-left"></div>
<div class="status-right" id="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>
+6 -6
View File
@@ -59,8 +59,8 @@ function isMemoryViewActive(view) {
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
}
function isUsageActive() {
return state.route === 'usage';
function isActivityActive() {
return state.route === 'activity';
}
function isProjectActive(slug) {
@@ -73,8 +73,8 @@ function handleSidebarRoute(routeName) {
setRoute(routeName);
if (routeName === 'sessions') {
router.push('/sessions');
} else if (routeName === 'usage') {
router.push('/usage');
} else if (routeName === 'activity') {
router.push('/activity');
} else {
router.push('/memory');
}
@@ -175,7 +175,7 @@ function handleProjectSearch(e) {
<button
class="sidebar-item"
:class="{ active: isUsageActive() }"
:class="{ active: isActivityActive() }"
@click="handleSidebarRoute('usage')"
>
<span class="icon">
@@ -183,7 +183,7 @@ function handleProjectSearch(e) {
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
</svg>
</span>
<span class="label">Usage</span>
<span class="label">Activity</span>
</button>
</div>
+2 -2
View File
@@ -44,8 +44,8 @@ const breadcrumbs = computed(() => {
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 });
} else if (name === 'Activity') {
crumbs.push({ label: 'Activity', terminal: true });
}
return crumbs;
+10 -7
View File
@@ -22,16 +22,19 @@ export async function loadInitialData() {
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: [],
anchors: m.anchors ? (typeof m.anchors === 'string' ? JSON.parse(m.anchors) : m.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: []
}));
// Sessions: merge with existing data to preserve already-loaded messages
const existingSessions = new Map(state.sessions.map(s => [s.id, s]));
state.sessions = (rawSessions || []).map(s => {
const existing = existingSessions.get(s.id);
return {
...s,
messages: existing?.messages?.length ? existing.messages : []
};
});
state.projects = projects || [];
state.stats = stats || {};
+12 -1
View File
@@ -17,9 +17,20 @@ const app = createApp(App);
app.use(router);
// Load data before the first render completes
// Load data on startup
router.isReady().then(() => {
loadInitialData();
});
// Refresh data when window regains focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
loadInitialData();
}
});
window.obelisk?.onIndexUpdated?.(() => {
loadInitialData();
});
app.mount('#app');
+4 -4
View File
@@ -9,7 +9,7 @@ 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 Activity = () => import('./views/Activity.vue');
const routes = [
{
@@ -42,9 +42,9 @@ const routes = [
props: true
},
{
path: '/usage',
name: 'Usage',
component: Usage
path: '/activity',
name: 'Activity',
component: Activity
},
{
path: '/',
+6 -4
View File
@@ -154,10 +154,12 @@ export function positionTooltip(el, x, y) {
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('/');
// Find the shortest project_path for this slug (most likely the project root)
const sessions = state.sessions.filter(s => s.project === slug && s.project_path);
if (sessions.length) {
const shortest = sessions.reduce((a, b) => a.project_path.length <= b.project_path.length ? a : b);
const parts = shortest.project_path.split('/');
return parts[parts.length - 1];
}
return slug.replace(/^-/, '');
}
@@ -3,7 +3,7 @@ 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' });
defineOptions({ name: 'Activity' });
// --- State ---
const activeTab = ref('daily');
+110 -2
View File
@@ -1,8 +1,116 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadMemoryMarkdown, archiveMemory, restoreMemory, isTextTruncated } from '../data.js';
import { escapeHTML, fmtRelative, renderMarkdown, formatProjectLabel } from '../utils.js';
defineOptions({ name: 'MemoryDetail' });
defineProps({ id: String });
const props = defineProps({ id: String });
const router = useRouter();
const memory = computed(() => state.memories.find(m => m.id === props.id));
const markdown = ref(null);
const showSource = ref(false);
const loading = ref(false);
onMounted(async () => { await loadContent(); });
watch(() => props.id, async () => { markdown.value = null; showSource.value = false; await loadContent(); });
async function loadContent() {
const m = memory.value;
if (!m) return;
if (m.markdown != null) { markdown.value = m.markdown; return; }
if (m.path) {
loading.value = true;
const content = await loadMemoryMarkdown(m.path);
m.markdown = content;
markdown.value = content;
loading.value = false;
}
}
async function handleArchive() {
const m = memory.value;
if (!m) return;
if (m.archived) await restoreMemory(m.id);
else await archiveMemory(m.id);
router.push('/memory');
}
function goToSession() {
const m = memory.value;
if (m?.session_id) router.push(`/sessions/${m.session_id}`);
}
</script>
<template>
<div class="view-placeholder">MemoryDetail view for {{ id }} (TODO)</div>
<div class="detail" v-if="memory">
<div class="detail-header">
<div class="detail-eyebrow">
<span class="project-icon" v-html="FOLDER_SVG"></span>
<span class="project-name">{{ formatProjectLabel(memory.project) }}</span>
<span v-if="memory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ memory.path }}</div>
<div class="detail-summary">{{ memory.summary }}</div>
<div class="detail-meta">
<button v-if="memory.session_id" class="session-link" @click="goToSession">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" style="width:11px;height:11px;">
<path d="M3 4h10v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4z"/>
<path d="M5.5 7h5M5.5 9.5h3" stroke-linecap="round"/>
</svg>
<span>Source session</span>
</button>
<span class="dot" v-if="memory.session_id"></span>
<span>created {{ fmtRelative(memory.ts) }}</span>
<template v-if="memory.message_start">
<span class="dot"></span>
<span style="font-family:var(--font-mono);font-size:11px;">{{ memory.message_start.slice(0, 8) }} {{ (memory.message_end || '').slice(0, 8) }}</span>
</template>
</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="markdown == null"
@click="showSource = !showSource"
>{{ showSource ? 'Show rendered' : 'Show source' }}</button>
</div>
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading</div>
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
<pre v-else-if="showSource" class="markdown-source">{{ markdown }}</pre>
<div v-else v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
<span>Anchors</span><span class="count">{{ memory.anchors.length }}</span>
</div>
<div v-if="memory.anchors && memory.anchors.length" class="anchor-list">
<button
v-for="a in memory.anchors"
:key="a.path + ':' + a.line"
class="anchor-link"
:disabled="a.exists === false"
:title="a.exists === false ? 'File no longer exists' : 'Open in editor'"
>
<span class="anchor-icon">
<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>
</span>
<span class="anchor-path">{{ a.path }}</span>
<span class="anchor-line" v-if="a.line">:{{ a.line }}</span>
</button>
</div>
<div class="detail-actions">
<button class="btn" @click="router.push('/memory')">Back</button>
<button class="btn" :class="memory.archived ? 'primary' : 'danger'" @click="handleArchive">
{{ memory.archived ? 'Restore' : 'Archive' }}
</button>
</div>
</div>
</template>
+12 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted, nextTick, onActivated } from 'vue';
import { ref, computed, onMounted, nextTick, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
@@ -27,18 +27,24 @@ const showBackToTop = ref(false);
const wrapRef = ref(null);
const detailRef = ref(null);
// --- Load session on mount ---
// --- Load session on mount or when id changes ---
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();
}
});
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
messages.value = [];
await loadMessages();
}
});
async function loadMessages() {
if (!props.id) return;
loading.value = true;
@@ -181,7 +187,9 @@ function getToolCallParsedInput(tc) {
</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>created {{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span>
<span class="dot"></span>
<span>last active {{ fmtRelative(new Date(session.ended_at || session.started_at || 0).getTime()) }}</span>
<span class="dot"></span>
<span>{{ session.message_count || 0 }} messages</span>
<template v-if="session.git_branch">
+36 -4
View File
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime } from '../utils.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative } from '../utils.js';
defineOptions({ name: 'SessionList' });
@@ -22,8 +22,8 @@ const visibleSessions = computed(() => {
})
.filter(Boolean)
.sort((a, b) => {
const ta = new Date(a.started_at || 0).getTime();
const tb = new Date(b.started_at || 0).getTime();
const ta = new Date(a.ended_at || a.started_at || 0).getTime();
const tb = new Date(b.ended_at || b.started_at || 0).getTime();
return state.sortDesc ? tb - ta : ta - tb;
});
});
@@ -39,13 +39,44 @@ function projectLabel(session) {
}
function timeLabel(session) {
const ts = new Date(session.started_at || 0).getTime();
const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts);
}
function lastActiveLabel(session) {
const ts = new Date(session.ended_at || session.started_at || 0).getTime();
return fmtListTime(ts);
}
function createdLabel(session) {
const ts = new Date(session.started_at || 0).getTime();
return fmtRelative(ts);
}
function openSession(session) {
router.push({ name: 'SessionDetail', params: { id: session.id } });
}
function obeliskStyle(session) {
const created = new Date(session.started_at || 0).getTime();
const days = Math.max(0, (Date.now() - created) / 86400000);
const height = Math.min(1, Math.log(1 + days) / Math.log(1 + 365));
let color;
if (days < 7) color = '#a855f7';
else if (days < 30) color = '#6366f1';
else if (days < 90) color = '#64748b';
else color = '#475569';
const glow = days < 7 ? `0 0 4px ${color}` : 'none';
const maxHeight = 36; // px, roughly the row height minus padding
return {
height: `${Math.max(4, Math.round(height * maxHeight))}px`,
background: color,
boxShadow: glow,
};
}
</script>
<template>
@@ -63,6 +94,7 @@ function openSession(session) {
:data-session-id="s.id"
@click="openSession(s)"
>
<div class="srow-obelisk" :style="obeliskStyle(s)"></div>
<div class="srow-body">
<div class="srow-title" v-html="titleHTML(s)"></div>
<div class="srow-meta">
+136 -2
View File
@@ -1,8 +1,142 @@
<script setup>
import { ref, onMounted, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { loadSubagentDetail, isTextTruncated, loadFullText } from '../data.js';
import { escapeHTML, fmtClockTime, renderMarkdown } from '../utils.js';
defineOptions({ name: 'SubagentDetail' });
defineProps({ id: String, agentId: String });
const props = defineProps({ id: String, agentId: String });
const router = useRouter();
const messages = ref([]);
const loading = ref(false);
const parentSession = computed(() => state.sessions.find(s => s.id === props.id));
onMounted(async () => { await load(); });
watch(() => props.agentId, async (n, o) => { if (n && n !== o) { messages.value = []; await load(); } });
async function load() {
if (!props.agentId) return;
loading.value = true;
try {
messages.value = await loadSubagentDetail(props.agentId);
} finally { loading.value = false; }
}
function goBack() {
router.push(`/sessions/${props.id}`);
}
async function handleLoadFull(uuid, el) {
const full = await loadFullText(uuid);
if (full && el) {
const body = el.closest('.msg')?.querySelector('.markdown-msg, .markdown-compact');
if (body) body.outerHTML = renderMarkdown(full, { variant: 'msg' });
el.remove();
}
}
</script>
<template>
<div class="view-placeholder">SubagentDetail view for agent {{ agentId }} in session {{ id }} (TODO)</div>
<div class="session-detail-wrap" ref="wrapRef">
<div class="detail-wide">
<div class="session-header">
<div class="session-eyebrow">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;">Subagent</span>
</div>
<div class="session-title">{{ agentId }}</div>
<div class="session-meta-inline">
<span>{{ messages.length }} messages</span>
</div>
</div>
<div v-if="loading" class="empty">Loading</div>
<div v-else class="timeline">
<div
v-for="(msg, idx) in messages"
:key="msg.uuid"
class="msg"
:class="[msg.type === 'user' ? 'user' : 'assistant']"
:data-uuid="msg.uuid"
>
<!-- Thinking -->
<template v-if="msg.content_type === 'thinking'">
<div class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<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' })"></div>
</div>
</template>
<!-- Meta -->
<template v-else-if="msg.is_meta">
<div class="msg-meta-collapsed">
<button class="meta-toggle" @click="$event.currentTarget.closest('.msg-meta-collapsed').classList.toggle('open')">
<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" v-html="renderMarkdown(msg.text, { variant: 'compact' })"></div>
</div>
</template>
<!-- Normal message -->
<template v-else>
<div class="msg-head">
<span class="role">{{ msg.type === 'user' ? 'Prompt' : 'Assistant' }}</span>
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
</div>
<div v-if="msg._thinking" class="msg-thinking">
<button class="thinking-toggle" @click="$event.currentTarget.closest('.msg-thinking').classList.toggle('open')">
<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' })"></div>
</div>
<div v-if="msg.text" v-html="renderMarkdown(msg.text, { variant: 'msg' })"></div>
<div v-else-if="!msg.tool_calls?.length" class="msg-text empty-text">(no text content)</div>
<button
v-if="isTextTruncated(msg.text)"
class="truncated-btn"
@click="handleLoadFull(msg.uuid, $event.currentTarget)"
>Message truncated click to load full text</button>
<!-- Tool calls -->
<div v-if="msg.tool_calls?.length" class="msg-tools">
<div v-for="tc in msg.tool_calls" :key="tc.id" class="msg-tool" :class="{ 'is-error': tc.result?.is_error }">
<button class="toolcall-toggle" @click="$event.currentTarget.closest('.msg-tool').classList.toggle('open')">
<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">{{ getToolArgPreview(tc) }}</span>
<span v-if="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>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
function getToolArgPreview(tc) {
try {
const j = JSON.parse(tc.input_json || '{}');
return j.file_path || j.command || j.path || j.description || JSON.stringify(j).slice(0, 100);
} catch { return (tc.input_json || '').slice(0, 100); }
}
</script>
+6
View File
@@ -116,6 +116,10 @@
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 2px; background: var(--muted-2);
}
.srow-obelisk {
position: absolute; left: 0; bottom: 0;
width: 3px; border-radius: 1.5px 1.5px 0 0;
}
.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);
@@ -146,7 +150,9 @@
color: var(--fg-2); text-align: right;
font-variant-numeric: tabular-nums;
flex-shrink: 0; padding-top: 2px; white-space: nowrap;
display: flex; flex-direction: column; gap: 2px;
}
.srow-right .srow-created { font-size: 10px; color: var(--muted); }
.empty {
flex: 1; display: flex; align-items: center; justify-content: center;
+1 -1
View File
@@ -34,7 +34,7 @@
.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%);
position: absolute; left: 14px; top: 12px; 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; }
+1 -1
View File
@@ -15,7 +15,7 @@
cursor: pointer; transition: all 0.1s;
display: inline-flex; align-items: center; gap: 6px;
line-height: 1; border: 0; background: transparent;
white-space: nowrap;
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; }