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:
@@ -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