feat(app): add Settings view, configurable claude dir, and recap docs refactor

Introduce a Settings view for configuring the Claude data directory
  (with WSL auto-detection on Windows), sidebar project grouping module,
  and an empty-state onboarding screen for SessionList. Refactor
  recap-patterns.md into per-card reference files under references/recap/
  with separate retrieval and writing guides. Remove the legacy panel.html.
  On the data layer: incremental indexing via changedPaths, per-session
  live-update IPC (obelisk:session-updated), and session dirty-tracking
  in the renderer.
This commit is contained in:
tommy0103
2026-06-15 01:44:32 +08:00
parent 9fc7f202f0
commit b7506ee765
48 changed files with 1950 additions and 1406 deletions
+13 -3
View File
@@ -18,6 +18,13 @@ const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
// --- Constants ---
const DAY_MS = 86400000;
function localDateStr(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const MONTHS_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
@@ -111,10 +118,13 @@ const longestStreak = computed(() => {
// --- Computed: weekly chart ---
const weeklyBars = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
let startDate = new Date(today.getTime() - 364 * DAY_MS);
startDate.setHours(0, 0, 0, 0);
const daysUntilSunday = (7 - startDate.getDay()) % 7;
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
// Align to Monday (ISO week start)
const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon...
const daysUntilMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
startDate = new Date(startDate.getTime() + daysUntilMonday * DAY_MS);
const dailyMap = {};
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
@@ -130,7 +140,7 @@ const weeklyBars = computed(() => {
const key = date.toISOString().slice(0, 10);
tokens += dailyMap[key] || 0;
}
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
weeks.push({ weekStart, tokens, weekKey: localDateStr(weekStart) });
}
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
+14 -2
View File
@@ -56,7 +56,19 @@ function statusGlyphs(status) {
}
function pathHTML(m) {
return highlightPlain(m.path || '', state.query.trim());
const full = m.path || '';
const filename = full.split('/').pop() || full;
return highlightPlain(filename, state.query.trim());
}
function relativePath(m) {
const full = m.path || '';
if (!m.project) return full;
const projectDir = '/' + m.project.replace(/^-/, '').replace(/-/g, '/');
if (full.startsWith(projectDir)) {
return full.slice(projectDir.length + 1);
}
return full.split('/').slice(-3).join('/');
}
function summaryHTML(m) {
@@ -301,7 +313,7 @@ onUnmounted(() => {
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
</div>
<div class="detail-path">{{ detailMemory.path }}</div>
<div class="detail-path">{{ relativePath(detailMemory) }}</div>
<div class="detail-summary">{{ detailMemory.summary }}</div>
<div class="detail-meta">
<span>{{ fmtRelative(detailMemory.ts) }}</span>
+15 -2
View File
@@ -15,6 +15,7 @@ const route = useRoute();
const recapData = ref(mockJson);
const currentArch = ref(mockJson.persona.archetype);
const currentIdx = ref(0);
const recapFilename = computed(() => String(route.params.id || ''));
const palette = computed(() => PALETTES[currentArch.value] || PALETTES.architect);
const CARD_LABELS = ['Cover', 'Path', 'Vibe', 'Workflow', 'Closing'];
@@ -59,10 +60,18 @@ onUnmounted(() => { unsubRecap?.(); });
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
async function exportImage() {
await window.obelisk.captureExport({ cardIdx: currentIdx.value, archetype: currentArch.value });
await window.obelisk.captureExport({
cardIdx: currentIdx.value,
archetype: currentArch.value,
filename: recapFilename.value,
});
}
async function copyImage() {
await window.obelisk.copyImage({ cardIdx: currentIdx.value, archetype: currentArch.value });
await window.obelisk.copyImage({
cardIdx: currentIdx.value,
archetype: currentArch.value,
filename: recapFilename.value,
});
}
function goTo(idx) {
@@ -91,6 +100,7 @@ function onKeydown(e) {
:arch-key="currentArch"
:badge="cover.badge"
:title="cover.title"
:claim="cover.claim || cover.subtitle"
:subtitle="cover.subtitle"
:activity="cover.activity"
:footer="cover.footer"
@@ -107,6 +117,7 @@ function onKeydown(e) {
<div class="card-slot" :class="{ active: currentIdx === 2, prev: currentIdx > 2 }">
<VibeCard
:title="vibe.title"
:voice-lines="vibe.voice_lines || vibe.observations"
:observations="vibe.observations"
:meter="vibe.meter"
:quote="vibe.quote"
@@ -116,6 +127,7 @@ function onKeydown(e) {
<div class="card-slot" :class="{ active: currentIdx === 3, prev: currentIdx > 3 }">
<WorkflowCard
:title="workflow.title"
:deck="workflow.deck || workflow.summary"
:summary="workflow.summary"
:stats="workflow.stats"
:items="workflow.items"
@@ -126,6 +138,7 @@ function onKeydown(e) {
<div class="card-slot" :class="{ active: currentIdx === 4, prev: currentIdx > 4 }">
<ClosingCard
:headline="closing.headline"
:receipts="closing.receipts || closing.stats"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase"
:signoff="closing.signoff"
+56 -15
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CoverCard from '../components/recap/CoverCard.vue';
import PathCard from '../components/recap/PathCard.vue';
@@ -10,15 +10,19 @@ import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import recapJson from '../mock/recap-2026-W24.json';
const route = useRoute();
const recapData = ref(recapJson);
window.__OBELISK_RECAP_EXPORT_READY__ = false;
const cardIdx = computed(() => parseInt(route.query.card) || 0);
const archKey = computed(() => route.query.arch || recapJson.persona.archetype);
const exportFilename = computed(() => typeof route.query.file === 'string' ? route.query.file : '');
const archKey = computed(() => route.query.arch || recapData.value.persona?.archetype || recapJson.persona.archetype);
const palette = computed(() => PALETTES[archKey.value] || PALETTES.architect);
const total = computed(() => recapData.value.cards?.length || 5);
const cover = recapJson.cards[0];
const path = recapJson.cards[1];
const vibe = recapJson.cards[2];
const workflow = recapJson.cards[3];
const closing = recapJson.cards[4];
const cover = computed(() => recapData.value.cards?.[0] || recapJson.cards[0]);
const path = computed(() => recapData.value.cards?.[1] || recapJson.cards[1]);
const vibe = computed(() => recapData.value.cards?.[2] || recapJson.cards[2]);
const workflow = computed(() => recapData.value.cards?.[3] || recapJson.cards[3]);
const closing = computed(() => recapData.value.cards?.[4] || recapJson.cards[4]);
const cssVars = computed(() => ({
'--tc': palette.value.tc,
@@ -28,33 +32,70 @@ const cssVars = computed(() => ({
'--tg-soft': palette.value.soft,
'--tg-edge': palette.value.soft,
}));
function setExportReady(value) {
window.__OBELISK_RECAP_EXPORT_READY__ = value;
}
async function markExportReady() {
await nextTick();
await new Promise(resolve => requestAnimationFrame(() => resolve()));
setExportReady(true);
}
let loadSeq = 0;
async function loadExportRecap(filename) {
const seq = ++loadSeq;
setExportReady(false);
try {
if (filename && window.obelisk?.recapRead) {
const data = await window.obelisk.recapRead(filename);
if (seq === loadSeq && data?.cards?.length) {
recapData.value = data;
} else if (seq === loadSeq) {
recapData.value = recapJson;
}
} else if (seq === loadSeq) {
recapData.value = recapJson;
}
} finally {
if (seq === loadSeq) await markExportReady();
}
}
onMounted(() => loadExportRecap(exportFilename.value));
watch(exportFilename, (filename) => loadExportRecap(filename));
</script>
<template>
<div class="export-wrap" :style="cssVars">
<CoverCard v-if="cardIdx === 0"
:arch-key="archKey" :badge="cover.badge" :title="cover.title"
:claim="cover.claim || cover.subtitle"
:subtitle="cover.subtitle" :activity="cover.activity" :footer="cover.footer"
:idx="1" :total="5"
:idx="1" :total="total"
/>
<PathCard v-else-if="cardIdx === 1"
:title="path.title" :items="path.items"
:idx="2" :total="5"
:idx="2" :total="total"
/>
<VibeCard v-else-if="cardIdx === 2"
:title="vibe.title" :observations="vibe.observations"
:title="vibe.title" :voice-lines="vibe.voice_lines || vibe.observations"
:observations="vibe.observations"
:meter="vibe.meter" :quote="vibe.quote"
:idx="3" :total="5"
:idx="3" :total="total"
/>
<WorkflowCard v-else-if="cardIdx === 3"
:title="workflow.title" :summary="workflow.summary"
:title="workflow.title" :deck="workflow.deck || workflow.summary"
:summary="workflow.summary"
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
:idx="4" :total="5"
:idx="4" :total="total"
/>
<ClosingCard v-else-if="cardIdx === 4"
:headline="closing.headline" :stats="closing.stats"
:headline="closing.headline" :receipts="closing.receipts || closing.stats"
:stats="closing.stats"
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
:idx="5" :total="5"
:idx="5" :total="total"
/>
</div>
</template>
+8 -8
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, inject } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
import { MINI_SEALS } from '../components/recap/seals.js';
import { CORNER_SEALS } from '../components/recap/seals.js';
defineOptions({ name: 'RecapList' });
@@ -27,7 +27,7 @@ function glowColor(arch) {
return PALETTES[arch]?.glow || PALETTES.architect.glow;
}
function sealSvg(arch) {
return MINI_SEALS[arch] || MINI_SEALS.architect;
return CORNER_SEALS[arch] || CORNER_SEALS.architect;
}
function formatDateRange(r) {
if (!r.period) return '';
@@ -116,7 +116,7 @@ onUnmounted(() => { unsub?.(); });
<span>{{ formatDateRange(r) }}</span>
</div>
<div class="recap-archetype">{{ r.persona?.title }}</div>
<div class="recap-subtitle">{{ r.persona?.subtitle }}</div>
<div class="recap-subtitle">{{ r.persona?.claim || r.persona?.subtitle }}</div>
<div class="recap-stats">
<span>{{ r.metrics?.sessions || 0 }} sessions</span>
<span class="sep">·</span>
@@ -266,7 +266,7 @@ onUnmounted(() => { unsub?.(); });
.timeline { position: relative; }
.timeline::before {
content: ''; position: absolute;
left: 15px; top: 15px; bottom: 15px;
left: 32px; top: 32px; bottom: 32px;
width: 1px; margin-left: -0.5px;
background: linear-gradient(to bottom,
rgba(167,139,250,0.55) 0%, rgba(167,139,250,0.35) 8%,
@@ -276,19 +276,19 @@ onUnmounted(() => { unsub?.(); });
.recap-row {
position: relative; display: grid;
grid-template-columns: 30px 1fr;
column-gap: 28px; align-items: center;
grid-template-columns: 64px 1fr;
column-gap: 18px; align-items: center;
padding: 12px 0; cursor: pointer;
transition: transform 0.12s;
}
.recap-row:hover { transform: translateX(2px); }
.recap-node {
width: 30px; height: 30px;
width: 64px; height: 64px;
position: relative; z-index: 2;
}
.recap-node::before {
content: ''; position: absolute; inset: -3px;
content: ''; position: absolute; inset: -2px;
border-radius: 50%; background: var(--bg); z-index: -1;
}
.recap-node :deep(svg) {
+75 -18
View File
@@ -1,8 +1,9 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, watch } from 'vue';
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, onDeactivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { state, FOLDER_SVG } from '../store.js';
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs';
import {
escapeHTML,
fmtRelative,
@@ -21,6 +22,9 @@ const session = computed(() => state.sessions.find(s => s.id === props.id));
const messages = ref([]);
const loading = ref(false);
const progressPct = ref(0);
const active = ref(false);
let removeSessionUpdated = null;
let keydownAttached = false;
// DOM refs
const wrapRef = ref(null);
@@ -58,51 +62,91 @@ function handleZoom(e) {
}
}
function attachKeydown() {
if (keydownAttached) return;
window.addEventListener('keydown', handleZoom);
keydownAttached = true;
}
function detachKeydown() {
if (!keydownAttached) return;
window.removeEventListener('keydown', handleZoom);
keydownAttached = false;
}
const HINT_KEY = 'obelisk:font-hint-shown';
const showFontHint = ref(false);
onMounted(async () => {
window.addEventListener('keydown', handleZoom);
active.value = true;
attachKeydown();
removeSessionUpdated = window.obelisk?.onSessionUpdated?.(async ({ sessionId } = {}) => {
if (!active.value || !props.id || sessionId !== props.id) return;
clearSessionDirty(props.id);
await loadMessages({ force: true });
}) || null;
if (!localStorage.getItem(HINT_KEY)) {
showFontHint.value = true;
localStorage.setItem(HINT_KEY, '1');
setTimeout(() => { showFontHint.value = false; }, 4000);
}
await loadMessages();
await loadMessages({ force: consumeGlobalSessionDirty(props.id) });
});
onActivated(async () => {
window.addEventListener('keydown', handleZoom);
if (messages.value.length === 0 && props.id) {
await loadMessages();
active.value = true;
attachKeydown();
if (props.id && (messages.value.length === 0 || consumeGlobalSessionDirty(props.id))) {
await loadMessages({ force: true });
}
});
onDeactivated(() => {
active.value = false;
detachKeydown();
});
onUnmounted(() => {
window.removeEventListener('keydown', handleZoom);
active.value = false;
detachKeydown();
removeSessionUpdated?.();
removeSessionUpdated = null;
});
watch(() => props.id, async (newId, oldId) => {
if (newId && newId !== oldId) {
messages.value = [];
await loadMessages();
await loadMessages({ force: consumeGlobalSessionDirty(newId) });
}
});
async function loadMessages() {
async function loadMessages({ force = false } = {}) {
if (!props.id) return;
const wasAtBottom = wrapRef.value && (wrapRef.value.scrollHeight - wrapRef.value.scrollTop - wrapRef.value.clientHeight) < 50;
const prevScrollTop = wrapRef.value?.scrollTop || 0;
loading.value = true;
try {
const s = state.sessions.find(x => x.id === props.id);
if (s && (!s.messages || s.messages.length === 0)) {
if (s && (force || !s.messages || s.messages.length === 0)) {
const loaded = await loadSessionDetail(props.id);
if (loaded) Object.assign(s, loaded);
}
messages.value = s?.messages || [];
const latest = state.sessions.find(x => x.id === props.id);
messages.value = latest?.messages || [];
} finally {
loading.value = false;
}
nextTick(() => {
if (!wrapRef.value) return;
if (wasAtBottom) {
wrapRef.value.scrollTop = wrapRef.value.scrollHeight;
} else {
wrapRef.value.scrollTop = prevScrollTop;
}
});
// Focus pending uuid if any
if (state.pendingFocusUuid) {
const targetUuid = state.pendingFocusUuid;
@@ -120,20 +164,25 @@ async function loadMessages() {
// --- Scroll / progress tracking ---
const currentMsgIdx = ref(0);
const totalMsgs = ref(0);
let navLock = false;
function onScroll() {
if (navLock) return;
if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
if (!msgs.length) return;
totalMsgs.value = msgs.length;
const wrapTop = wrapRef.value.getBoundingClientRect().top;
let topMsgIdx = 0;
const el = wrapRef.value;
const navHeight = 52;
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
let bottomMsgIdx = 0;
for (let i = 0; i < msgs.length; i++) {
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
if (msgs[i].getBoundingClientRect().bottom <= bottomLine) bottomMsgIdx = i;
else break;
}
currentMsgIdx.value = topMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
currentMsgIdx.value = bottomMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct;
}
@@ -147,8 +196,16 @@ function navTo(target) {
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
else return;
const isClose = Math.abs(idx - currentMsgIdx.value) <= 3;
msgs[idx]?.scrollIntoView({ behavior: isClose ? 'smooth' : 'instant', block: 'start' });
currentMsgIdx.value = idx;
navLock = true;
const navHeight = 52;
const el = wrapRef.value;
const msgEl = msgs[idx];
if (!msgEl) return;
const msgBottom = msgEl.offsetTop + msgEl.offsetHeight;
const scrollTarget = msgBottom - el.clientHeight + navHeight;
el.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'instant' });
setTimeout(() => { navLock = false; }, 50);
}
// --- Toggle helpers ---
+118 -2
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative } from '../utils.js';
@@ -7,6 +7,17 @@ import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelativ
defineOptions({ name: 'SessionList' });
const router = useRouter();
const debugEmpty = ref(false);
function onKeydown(e) {
if (e.key === 'm' && !e.metaKey && !e.ctrlKey && e.target.tagName !== 'INPUT') {
debugEmpty.value = !debugEmpty.value;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
const homePath = (typeof process !== 'undefined' && process.env?.HOME) || '~';
const visibleSessions = computed(() => {
const q = state.query.trim().toLowerCase();
@@ -81,10 +92,47 @@ function obeliskStyle(session) {
<template>
<div class="session-list-wrap">
<div v-if="!visibleSessions.length" class="empty">
<!-- Empty state: no data source / debug toggle -->
<div v-if="debugEmpty || (!visibleSessions.length && !state.query)" class="empty-content">
<div class="empty-eyebrow">
<span class="diamond"></span>
<span>No data source connected</span>
</div>
<div class="empty-title">Obelisk reads your Claude Code session history.</div>
<div class="empty-body">
We didn't find <code>~/.claude</code> on this machine. If you've already used
Claude Code, point Obelisk at where its data lives in
<button class="inline-link" @click="router.push('/settings')">Settings</button>. If you haven't,
<strong>install Claude Code first</strong> Obelisk has nothing to read until
sessions exist.
</div>
<div class="empty-actions">
<button class="toolbar-action primary" @click="router.push('/settings')">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
</svg>
Choose folder
</button>
</div>
<div class="empty-divider"></div>
<div class="empty-help">
<div class="help-row">
<span class="label">expected</span>
<code>~/.claude</code>
</div>
<div class="help-row">
<span class="label">searched</span>
<code>{{ homePath }}</code>
</div>
</div>
</div>
<!-- Empty state: search returned nothing -->
<div v-else-if="!visibleSessions.length" class="empty">
No sessions here.
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
</div>
<div v-else class="session-list">
<div
v-for="s in visibleSessions"
@@ -116,6 +164,8 @@ function obeliskStyle(session) {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.srow {
@@ -217,4 +267,70 @@ function obeliskStyle(session) {
font-size: 11px;
color: var(--muted-2);
}
/* Onboarding empty state */
.empty-content {
flex: 1;
display: flex; flex-direction: column; gap: 16px;
max-width: 520px;
margin: 0 auto;
justify-content: center;
padding: 40px;
}
.empty-eyebrow {
display: flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 11px;
color: var(--muted); letter-spacing: 0.04em;
}
.empty-eyebrow .diamond {
width: 6px; height: 6px;
background: var(--accent, #a78bfa); transform: rotate(45deg);
box-shadow: 0 0 6px rgba(167,139,250,0.4); flex-shrink: 0;
}
.empty-title {
font-family: var(--font-serif, Georgia); font-size: 22px;
font-weight: 500; color: var(--fg);
letter-spacing: -0.015em; line-height: 1.2;
}
.empty-body {
font-family: var(--font-serif, Georgia); font-style: italic;
font-size: 14px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
}
.empty-body code {
font-family: var(--font-mono); font-style: normal; font-size: 12.5px;
color: var(--accent-2, #c4b5fd); background: rgba(167,139,250,0.12);
padding: 1px 6px; border-radius: 3px;
}
.empty-body strong { color: var(--fg); font-weight: 600; font-style: normal; }
.empty-body .inline-link {
color: var(--accent-2, #c4b5fd); background: none;
border: none; border-bottom: 1px solid rgba(167,139,250,0.4);
padding: 0 0 1px; font: inherit; cursor: pointer; transition: all 0.12s;
}
.empty-body .inline-link:hover { color: var(--accent, #a78bfa); border-bottom-color: var(--accent); }
.empty-actions { display: flex; gap: 8px; margin-top: 6px; }
.empty-actions .toolbar-action {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 14px; border-radius: 5px;
font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.12s;
}
.empty-actions .toolbar-action.primary {
border: 1px solid rgba(167,139,250,0.35); background: rgba(167,139,250,0.12); color: #c4b5fd;
}
.empty-actions .toolbar-action.primary:hover {
background: rgba(167,139,250,0.18); border-color: #a78bfa; color: var(--fg);
box-shadow: 0 0 12px rgba(167,139,250,0.2);
}
.empty-actions .toolbar-action svg { width: 13px; height: 13px; }
.empty-divider { width: 100%; height: 1px; background: var(--hairline); margin: 6px 0; }
.empty-help {
display: flex; flex-direction: column; gap: 6px;
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
}
.empty-help .help-row { display: flex; align-items: baseline; gap: 8px; }
.empty-help .help-row .label { color: var(--muted-2); letter-spacing: 0.04em; width: 76px; flex-shrink: 0; }
.empty-help code {
font-family: var(--font-mono); color: var(--fg-2);
background: rgba(0,0,0,0.3); padding: 1px 6px; border-radius: 3px;
}
</style>
+372
View File
@@ -0,0 +1,372 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
defineOptions({ name: 'Settings' });
const claudePath = ref('');
const dbPath = ref('');
const recapPath = ref('');
const autoRefresh = ref(true);
const status = ref('ok');
const statusText = ref('Connected');
const sessionCount = ref(0);
const memoryCount = ref(0);
const lastIndexed = ref('');
const rebuilding = ref(false);
const version = ref('0.1.0');
onMounted(async () => {
await loadSettings();
});
async function loadSettings() {
if (!window.obelisk?.getSettings) return;
const s = await window.obelisk.getSettings();
claudePath.value = s.claudeDir || '~/.claude';
dbPath.value = s.dbPath || '';
recapPath.value = s.recapDir || '~/.obelisk/recap';
autoRefresh.value = s.autoRefresh !== false;
sessionCount.value = s.sessionCount || 0;
memoryCount.value = s.memoryCount || 0;
lastIndexed.value = s.lastIndexed || '';
status.value = s.status || 'ok';
statusText.value = s.statusText || 'Connected';
}
async function browsePath() {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
claudePath.value = result;
await saveSetting('claudeDir', result);
await loadSettings();
}
}
async function browseRecapPath() {
if (!window.obelisk?.browseFolder) return;
const result = await window.obelisk.browseFolder();
if (result) {
recapPath.value = result;
await saveSetting('recapDir', result);
}
}
async function resetPath() {
await saveSetting('claudeDir', null);
await loadSettings();
}
async function toggleAutoRefresh() {
autoRefresh.value = !autoRefresh.value;
await saveSetting('autoRefresh', autoRefresh.value);
}
async function saveSetting(key, value) {
if (window.obelisk?.setSetting) {
await window.obelisk.setSetting(key, value);
}
}
async function commitClaudePath() {
await saveSetting('claudeDir', claudePath.value);
await loadSettings();
}
async function commitRecapPath() {
await saveSetting('recapDir', recapPath.value);
}
async function rebuildIndex() {
if (rebuilding.value || !window.obelisk?.rebuildIndex) return;
rebuilding.value = true;
statusText.value = 'Rebuilding…';
try {
await window.obelisk.rebuildIndex();
await loadSettings();
} finally {
rebuilding.value = false;
}
}
async function revealDb() {
if (window.obelisk?.revealPath) {
window.obelisk.revealPath(dbPath.value);
}
}
function fmtRelative(iso) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'just now';
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
</script>
<template>
<div class="settings-wrap">
<div class="settings-content">
<!-- Data Source -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Data Source</h2>
<p>Where Obelisk reads your Claude Code session history.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Claude Code path</div>
<div class="form-label-hint">Default <code>~/.claude</code> on macOS &amp; Linux.</div>
</div>
<div class="form-control">
<div class="path-input">
<input
class="path-field"
:class="{ error: status === 'error' }"
type="text"
v-model="claudePath"
spellcheck="false"
@keydown.enter="commitClaudePath"
@blur="commitClaudePath"
/>
<button class="btn" @click="browsePath">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="M2.5 3.5h3.5l1.2 1.2h4.3a1 1 0 0 1 1 1V11a1 1 0 0 1-1 1H2.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1z"/>
</svg>
Browse
</button>
<button class="btn subtle" @click="resetPath" title="Reset to default">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.5 6.5A5 5 0 1 0 12 9.5"/>
<path d="M12.5 2v4.5h-4.5"/>
</svg>
</button>
</div>
<div class="status-row" :class="status">
<span class="status-dot" :class="status"></span>
<span class="status-text">{{ statusText }}</span>
<div class="status-meta" v-if="sessionCount || lastIndexed">
<template v-if="lastIndexed">
<span>last read <strong>{{ fmtRelative(lastIndexed) }}</strong></span>
<span class="sep">·</span>
</template>
<span><strong>{{ sessionCount }}</strong> sessions</span>
<span class="sep">·</span>
<span><strong>{{ memoryCount }}</strong> memories</span>
</div>
</div>
</div>
</div>
<div class="form-row">
<div>
<div class="form-label">Index location</div>
<div class="form-label-hint">SQLite database where Obelisk caches the session index.</div>
</div>
<div class="form-control">
<div class="path-input">
<input class="path-field" type="text" :value="dbPath" spellcheck="false" readonly/>
<button class="btn" @click="revealDb">Reveal</button>
</div>
</div>
</div>
<div class="form-row">
<div>
<div class="form-label">Auto-refresh</div>
<div class="form-label-hint">Obelisk re-reads when new session files appear.</div>
</div>
<div class="form-control">
<label class="toggle-label" @click.prevent="toggleAutoRefresh">
<span class="toggle-track" :class="{ on: autoRefresh }">
<span class="toggle-thumb"></span>
</span>
<span class="toggle-text">Watch <code>.claude</code> for changes</span>
</label>
</div>
</div>
</section>
<!-- Recap -->
<section class="settings-section">
<div class="settings-section-head">
<h2>Recap</h2>
<p>Where generated weekly and monthly recap files live.</p>
</div>
<div class="form-row">
<div>
<div class="form-label">Recap output directory</div>
<div class="form-label-hint">Watched by Obelisk for new <code>recap-*.json</code> files.</div>
</div>
<div class="form-control">
<div class="path-input">
<input
class="path-field"
type="text"
v-model="recapPath"
spellcheck="false"
@keydown.enter="commitRecapPath"
@blur="commitRecapPath"
/>
<button class="btn" @click="browseRecapPath">Browse</button>
</div>
</div>
</div>
</section>
<!-- About -->
<section class="settings-section last">
<div class="settings-section-head">
<h2>About</h2>
<p>The kind of details you don't usually need.</p>
</div>
<div class="form-row">
<div class="form-label">Version</div>
<div class="form-control version-text">
Obelisk {{ version }}
</div>
</div>
<div class="form-row">
<div class="form-label">Reset</div>
<div class="form-control">
<div class="reset-actions">
<button class="btn" :disabled="rebuilding" @click="rebuildIndex">
{{ rebuilding ? 'Rebuilding' : 'Rebuild index' }}
</button>
</div>
<div class="reset-hint">
Rebuilding only re-reads your Claude Code data. It does not delete memories or recaps.
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.settings-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.settings-content { max-width: 720px; margin: 0 auto; padding: 36px 32px 80px; }
.settings-section { margin-bottom: 44px; }
.settings-section.last { margin-bottom: 0; }
.settings-section-head {
margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline);
}
.settings-section-head h2 {
font-size: 18px; font-weight: 600;
color: var(--fg); letter-spacing: -0.01em; margin-bottom: 2px;
}
.settings-section-head p {
font-size: 13px; color: var(--muted);
}
.form-row {
display: grid; grid-template-columns: 180px 1fr;
gap: 24px; padding: 14px 0; align-items: start;
}
.form-row + .form-row { border-top: 1px solid var(--hairline); }
.form-label { font-size: 13px; color: var(--fg-2); font-weight: 500; padding-top: 6px; }
.form-label-hint {
font-size: 11.5px; color: var(--muted); margin-top: 4px; font-weight: 400;
}
.form-label-hint code {
font-family: var(--font-mono); font-style: normal; font-size: 10.5px;
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px; color: var(--muted);
}
.form-control { display: flex; flex-direction: column; gap: 8px; }
.path-input { display: flex; gap: 6px; }
.path-field {
flex: 1; height: 28px; padding: 0 10px;
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline-strong);
border-radius: 5px; font-family: var(--font-mono); font-size: 12px;
color: var(--fg); min-width: 0; transition: all 0.12s;
}
.path-field:focus { outline: 0; border-color: var(--accent); background: rgba(0,0,0,0.4); box-shadow: 0 0 0 2px rgba(167,139,250,0.12); }
.path-field.error { border-color: rgba(248,113,113,0.4); }
.path-field.error:focus { border-color: #f87171; box-shadow: 0 0 0 2px rgba(248,113,113,0.12); }
.tz-field { max-width: 240px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 12px;
border: 1px solid var(--hairline-strong); border-radius: 5px;
background: var(--surface); color: var(--fg-2);
font-size: 12px; font-weight: 500; cursor: pointer;
transition: all 0.12s; white-space: nowrap;
}
.btn:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.subtle { background: transparent; border-color: transparent; color: var(--muted); }
.btn.subtle:hover { background: var(--surface); color: var(--fg-2); }
.btn svg { width: 13px; height: 13px; }
.status-row {
display: flex; align-items: center; gap: 14px;
padding: 8px 12px; background: rgba(0,0,0,0.2);
border: 1px solid var(--hairline); border-radius: 5px;
font-family: var(--font-mono); font-size: 11.5px; flex-wrap: wrap;
}
.status-row.ok { border-color: rgba(52,211,153,0.20); background: rgba(52,211,153,0.04); }
.status-row.warn { border-color: rgba(251,191,36,0.20); background: rgba(251,191,36,0.04); }
.status-row.error { border-color: rgba(248,113,113,0.20); background: rgba(248,113,113,0.04); }
.status-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
position: relative;
}
.status-dot.ok { background: #34d399; box-shadow: 0 0 6px rgba(52,211,153,0.5); }
.status-dot.warn { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,0.5); }
.status-dot.error { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.5); }
.status-dot.ok::before {
content: ''; position: absolute; inset: -3px;
border-radius: 50%; border: 1px solid #34d399; opacity: 0.5;
animation: pulse 1.6s ease-out infinite;
}
@keyframes pulse { 0% { transform: scale(0.8); opacity: 0.5; } 100% { transform: scale(1.6); opacity: 0; } }
.status-text { color: var(--fg-2); font-weight: 500; }
.status-text.error { color: #f87171; }
.status-meta { display: flex; gap: 6px; color: var(--muted); align-items: center; flex-wrap: wrap; }
.status-meta strong { color: var(--fg-2); font-weight: 500; }
.status-meta .sep { color: var(--muted-2); }
.toggle-label { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.toggle-input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle-track {
position: relative; width: 30px; height: 16px;
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
border-radius: 8px; transition: all 0.15s;
}
.toggle-track.on { background: rgba(167,139,250,0.12); border-color: rgba(167,139,250,0.5); }
.toggle-thumb {
position: absolute; top: 2px; left: 2px;
width: 10px; height: 10px; border-radius: 50%;
background: var(--muted); transition: all 0.15s;
}
.toggle-track.on .toggle-thumb {
left: 16px; background: #c4b5fd;
box-shadow: 0 0 6px rgba(167,139,250,0.5);
}
.toggle-text { font-size: 12.5px; color: var(--fg-2); }
.toggle-text code {
font-family: var(--font-mono); font-size: 11px;
padding: 1px 4px; background: rgba(0,0,0,0.3); border-radius: 3px;
}
.version-text {
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2); padding-top: 6px;
}
.reset-actions { display: flex; gap: 8px; }
.reset-hint {
font-size: 11.5px; color: var(--muted); margin-top: 6px;
}
</style>