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