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:
+162
-206
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '/',
|
||||
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user