feat(app): add weekly recap cards with swipeable story UI and export
Introduce a Spotify-Wrapped-style recap feature: five themed cards (Cover, Path, Vibe, Workflow, Closing) rendered per archetype palette, with keyboard/swipe navigation and image export via capture IPC. Add RecapList, RecapDetail, RecapExport views and recap component library. Wire recap:list/read/updated IPC channels through preload, document the retrieval-to-card contract in references/recap-patterns.md, and bundle dist-renderer for production use.
This commit is contained in:
@@ -84,7 +84,7 @@ function goToSession() {
|
||||
<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 v-else class="markdown-msg" v-html="renderMarkdown(markdown, { variant: 'body' })"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CoverCard from '../components/recap/CoverCard.vue';
|
||||
import PathCard from '../components/recap/PathCard.vue';
|
||||
import VibeCard from '../components/recap/VibeCard.vue';
|
||||
import WorkflowCard from '../components/recap/WorkflowCard.vue';
|
||||
import ClosingCard from '../components/recap/ClosingCard.vue';
|
||||
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
|
||||
import mockJson from '../mock/recap-2026-W24.json';
|
||||
|
||||
defineOptions({ name: 'RecapDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const recapData = ref(mockJson);
|
||||
const currentArch = ref(mockJson.persona.archetype);
|
||||
const currentIdx = ref(0);
|
||||
|
||||
const palette = computed(() => PALETTES[currentArch.value] || PALETTES.architect);
|
||||
const CARD_LABELS = ['Cover', 'Path', 'Vibe', 'Workflow', 'Closing'];
|
||||
const TOTAL = computed(() => recapData.value.cards.length);
|
||||
|
||||
const cover = computed(() => recapData.value.cards[0]);
|
||||
const path = computed(() => recapData.value.cards[1]);
|
||||
const vibe = computed(() => recapData.value.cards[2]);
|
||||
const workflow = computed(() => recapData.value.cards[3]);
|
||||
const closing = computed(() => recapData.value.cards[4]);
|
||||
|
||||
const cssVars = computed(() => ({
|
||||
'--tc': palette.value.tc,
|
||||
'--tc-2': palette.value.tc2,
|
||||
'--tg': palette.value.glow,
|
||||
'--tg-mid': palette.value.mid,
|
||||
'--tg-soft': palette.value.soft,
|
||||
'--tg-edge': palette.value.soft,
|
||||
}));
|
||||
|
||||
async function loadRecap(filename) {
|
||||
if (!filename || !window.obelisk?.recapRead) return;
|
||||
const data = await window.obelisk.recapRead(filename);
|
||||
if (data?.cards?.length) {
|
||||
recapData.value = data;
|
||||
currentArch.value = data.persona?.archetype || 'architect';
|
||||
currentIdx.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let unsubRecap;
|
||||
onMounted(async () => {
|
||||
const filename = route.params.id;
|
||||
if (filename) await loadRecap(filename);
|
||||
if (window.obelisk?.onRecapUpdated) {
|
||||
unsubRecap = window.obelisk.onRecapUpdated((fp) => {
|
||||
if (fp.endsWith(route.params.id)) loadRecap(route.params.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
onUnmounted(() => { unsubRecap?.(); });
|
||||
watch(() => route.params.id, (id) => { if (id) loadRecap(id); });
|
||||
|
||||
async function exportImage() {
|
||||
await window.obelisk.captureExport({ cardIdx: currentIdx.value, archetype: currentArch.value });
|
||||
}
|
||||
async function copyImage() {
|
||||
await window.obelisk.copyImage({ cardIdx: currentIdx.value, archetype: currentArch.value });
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx >= 0 && idx < TOTAL.value) currentIdx.value = idx;
|
||||
}
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); goTo(currentIdx.value - 1); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); goTo(currentIdx.value + 1); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); goTo(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); goTo(TOTAL.value - 1); }
|
||||
else if (e.key === 'p') {
|
||||
const i = ARCH_KEYS.indexOf(currentArch.value);
|
||||
currentArch.value = ARCH_KEYS[(i + 1) % ARCH_KEYS.length];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recap-app" :style="cssVars" @keydown="onKeydown" tabindex="0">
|
||||
|
||||
<!-- Stage -->
|
||||
<div class="stage">
|
||||
<div class="deck">
|
||||
<div class="card-slot" :class="{ active: currentIdx === 0, prev: currentIdx > 0 }">
|
||||
<CoverCard
|
||||
:arch-key="currentArch"
|
||||
:badge="cover.badge"
|
||||
:title="cover.title"
|
||||
:subtitle="cover.subtitle"
|
||||
:activity="cover.activity"
|
||||
:footer="cover.footer"
|
||||
:idx="1" :total="TOTAL"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-slot" :class="{ active: currentIdx === 1, prev: currentIdx > 1 }">
|
||||
<PathCard
|
||||
:title="path.title"
|
||||
:items="path.items"
|
||||
:idx="2" :total="TOTAL"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-slot" :class="{ active: currentIdx === 2, prev: currentIdx > 2 }">
|
||||
<VibeCard
|
||||
:title="vibe.title"
|
||||
:observations="vibe.observations"
|
||||
:meter="vibe.meter"
|
||||
:quote="vibe.quote"
|
||||
:idx="3" :total="TOTAL"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-slot" :class="{ active: currentIdx === 3, prev: currentIdx > 3 }">
|
||||
<WorkflowCard
|
||||
:title="workflow.title"
|
||||
:summary="workflow.summary"
|
||||
:stats="workflow.stats"
|
||||
:items="workflow.items"
|
||||
:verdict="workflow.verdict"
|
||||
:idx="4" :total="TOTAL"
|
||||
/>
|
||||
</div>
|
||||
<div class="card-slot" :class="{ active: currentIdx === 4, prev: currentIdx > 4 }">
|
||||
<ClosingCard
|
||||
:headline="closing.headline"
|
||||
:stats="closing.stats"
|
||||
:most-said-phrase="closing.most_said_phrase"
|
||||
:signoff="closing.signoff"
|
||||
:idx="5" :total="TOTAL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<div class="nav">
|
||||
<button class="nav-arrow" :disabled="currentIdx === 0" @click="goTo(currentIdx - 1)">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 4l-4 4 4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-dots">
|
||||
<button
|
||||
v-for="(label, i) in CARD_LABELS" :key="i"
|
||||
class="nav-dot" :class="{ active: i === currentIdx }"
|
||||
@click="goTo(i)"
|
||||
>
|
||||
<div class="nav-dot-glyph"></div>
|
||||
<div class="nav-dot-label">{{ label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="nav-arrow" :disabled="currentIdx === TOTAL - 1" @click="goTo(currentIdx + 1)">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 4l4 4-4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="nav-actions">
|
||||
<button class="nav-action" title="Copy image" @click="copyImage">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="5" width="9" height="9" rx="1.5"/>
|
||||
<path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5v-7A1.5 1.5 0 0 1 3.5 1h7A1.5 1.5 0 0 1 12 2.5V5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="nav-action" title="Export PNG" @click="exportImage">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2v8M5 7l3 3 3-3"/>
|
||||
<path d="M3 12v1.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recap-app {
|
||||
--bg: #0a0b14;
|
||||
--bg-2: #11131f;
|
||||
--surface: rgba(255,255,255,0.03);
|
||||
--surface-strong: rgba(255,255,255,0.06);
|
||||
--surface-hi: rgba(255,255,255,0.09);
|
||||
--fg: rgba(255,255,255,0.94);
|
||||
--fg-2: rgba(255,255,255,0.74);
|
||||
--fg-3: rgba(255,255,255,0.55);
|
||||
--muted: rgba(255,255,255,0.48);
|
||||
--muted-2: rgba(255,255,255,0.28);
|
||||
--muted-3: rgba(255,255,255,0.16);
|
||||
--hairline: rgba(255,255,255,0.05);
|
||||
--hairline-strong: rgba(255,255,255,0.10);
|
||||
--hairline-vivid: rgba(255,255,255,0.16);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
|
||||
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
|
||||
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--transition-fast: 120ms ease;
|
||||
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 64px;
|
||||
color: var(--fg);
|
||||
font: 13px/1.45 var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.10), transparent 55%),
|
||||
radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.08), transparent 60%),
|
||||
radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.10), transparent 60%),
|
||||
linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%);
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
.recap-app::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none; z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
.stage {
|
||||
position: relative; overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 32px 24px; z-index: 1;
|
||||
}
|
||||
.deck {
|
||||
position: relative; width: 100%; max-width: 540px;
|
||||
height: 100%; perspective: 2000px;
|
||||
}
|
||||
.card-slot {
|
||||
position: absolute; inset: 0;
|
||||
opacity: 0; transform: translateY(24px) scale(0.97);
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition), transform var(--transition);
|
||||
}
|
||||
.card-slot.active {
|
||||
opacity: 1; transform: translateY(0) scale(1);
|
||||
pointer-events: auto; z-index: 2;
|
||||
}
|
||||
.card-slot.prev {
|
||||
opacity: 0; transform: translateY(-12px) scale(1.02);
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||
padding: 0 22px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
background: rgba(0,0,0,0.18);
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.nav-arrow {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||
color: var(--fg-2); display: grid; place-items: center;
|
||||
cursor: pointer; transition: all var(--transition-fast);
|
||||
}
|
||||
.nav-arrow:hover:not(:disabled) { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
|
||||
.nav-arrow:disabled { cursor: default; opacity: 0.3; }
|
||||
.nav-arrow svg { width: 14px; height: 14px; }
|
||||
|
||||
.nav-dots { display: flex; gap: 8px; padding: 0 4px; }
|
||||
.nav-dot {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
cursor: pointer; padding: 4px 8px; border-radius: 4px;
|
||||
background: none; border: none; color: inherit;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.nav-dot:hover { background: var(--surface); }
|
||||
.nav-dot-glyph {
|
||||
width: 24px; height: 3px; border-radius: 2px;
|
||||
background: var(--muted-3); transition: all var(--transition);
|
||||
}
|
||||
.nav-dot.active .nav-dot-glyph {
|
||||
background: var(--tc); box-shadow: 0 0 8px var(--tg); width: 28px;
|
||||
transition: background var(--theme-ease), box-shadow var(--theme-ease), width var(--transition);
|
||||
}
|
||||
.nav-dot-label {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 11px; color: var(--muted-2);
|
||||
}
|
||||
.nav-dot.active .nav-dot-label { color: var(--fg-2); }
|
||||
|
||||
.nav-actions {
|
||||
position: absolute; right: 60px;
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
}
|
||||
.nav-action {
|
||||
width: 32px; height: 32px; border-radius: 6px;
|
||||
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||
color: var(--fg-2); display: grid; place-items: center;
|
||||
cursor: pointer; transition: all var(--transition-fast);
|
||||
}
|
||||
.nav-action:hover { background: var(--surface-strong); color: var(--fg); border-color: var(--hairline-vivid); }
|
||||
.nav-action svg { width: 14px; height: 14px; }
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CoverCard from '../components/recap/CoverCard.vue';
|
||||
import PathCard from '../components/recap/PathCard.vue';
|
||||
import VibeCard from '../components/recap/VibeCard.vue';
|
||||
import WorkflowCard from '../components/recap/WorkflowCard.vue';
|
||||
import ClosingCard from '../components/recap/ClosingCard.vue';
|
||||
import { PALETTES, ARCH_KEYS } from '../components/recap/archetypes.js';
|
||||
import recapJson from '../mock/recap-2026-W24.json';
|
||||
|
||||
const route = useRoute();
|
||||
const cardIdx = computed(() => parseInt(route.query.card) || 0);
|
||||
const archKey = computed(() => route.query.arch || recapJson.persona.archetype);
|
||||
const palette = computed(() => PALETTES[archKey.value] || PALETTES.architect);
|
||||
|
||||
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 cssVars = computed(() => ({
|
||||
'--tc': palette.value.tc,
|
||||
'--tc-2': palette.value.tc2,
|
||||
'--tg': palette.value.glow,
|
||||
'--tg-mid': palette.value.mid,
|
||||
'--tg-soft': palette.value.soft,
|
||||
'--tg-edge': palette.value.soft,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="export-wrap" :style="cssVars">
|
||||
<CoverCard v-if="cardIdx === 0"
|
||||
:arch-key="archKey" :badge="cover.badge" :title="cover.title"
|
||||
:subtitle="cover.subtitle" :activity="cover.activity" :footer="cover.footer"
|
||||
:idx="1" :total="5"
|
||||
/>
|
||||
<PathCard v-else-if="cardIdx === 1"
|
||||
:title="path.title" :items="path.items"
|
||||
:idx="2" :total="5"
|
||||
/>
|
||||
<VibeCard v-else-if="cardIdx === 2"
|
||||
:title="vibe.title" :observations="vibe.observations"
|
||||
:meter="vibe.meter" :quote="vibe.quote"
|
||||
:idx="3" :total="5"
|
||||
/>
|
||||
<WorkflowCard v-else-if="cardIdx === 3"
|
||||
:title="workflow.title" :summary="workflow.summary"
|
||||
:stats="workflow.stats" :items="workflow.items" :verdict="workflow.verdict"
|
||||
:idx="4" :total="5"
|
||||
/>
|
||||
<ClosingCard v-else-if="cardIdx === 4"
|
||||
:headline="closing.headline" :stats="closing.stats"
|
||||
:most-said-phrase="closing.most_said_phrase" :signoff="closing.signoff"
|
||||
:idx="5" :total="5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.export-wrap {
|
||||
--bg: #0a0b14;
|
||||
--bg-2: #11131f;
|
||||
--surface: rgba(255,255,255,0.03);
|
||||
--surface-strong: rgba(255,255,255,0.06);
|
||||
--fg: rgba(255,255,255,0.94);
|
||||
--fg-2: rgba(255,255,255,0.74);
|
||||
--fg-3: rgba(255,255,255,0.55);
|
||||
--muted: rgba(255,255,255,0.48);
|
||||
--muted-2: rgba(255,255,255,0.28);
|
||||
--muted-3: rgba(255,255,255,0.16);
|
||||
--hairline: rgba(255,255,255,0.05);
|
||||
--hairline-strong: rgba(255,255,255,0.10);
|
||||
--hairline-vivid: rgba(255,255,255,0.16);
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
|
||||
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
|
||||
--transition: 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--transition-fast: 120ms ease;
|
||||
--theme-ease: 380ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
width: 540px;
|
||||
height: 675px;
|
||||
position: relative;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 13px/1.45 var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,512 @@
|
||||
<script setup>
|
||||
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';
|
||||
|
||||
defineOptions({ name: 'RecapList' });
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const recaps = ref([]);
|
||||
const kind = computed(() => route.query.kind || 'weekly');
|
||||
const showGenerate = inject('recapGenerateOpen', ref(false));
|
||||
|
||||
const filtered = computed(() => recaps.value.filter(r => r.kind === kind.value));
|
||||
const byYear = computed(() => {
|
||||
const map = {};
|
||||
for (const r of filtered.value) {
|
||||
const y = r.period?.start?.slice(0, 4) || '?';
|
||||
if (!map[y]) map[y] = [];
|
||||
map[y].push(r);
|
||||
}
|
||||
return Object.entries(map).sort((a, b) => b[0] - a[0]);
|
||||
});
|
||||
|
||||
function glowColor(arch) {
|
||||
return PALETTES[arch]?.glow || PALETTES.architect.glow;
|
||||
}
|
||||
function sealSvg(arch) {
|
||||
return MINI_SEALS[arch] || MINI_SEALS.architect;
|
||||
}
|
||||
function formatDateRange(r) {
|
||||
if (!r.period) return '';
|
||||
const s = new Date(r.period.start);
|
||||
const e = new Date(r.period.end);
|
||||
const mo = s.toLocaleString('en', { month: 'short' });
|
||||
return `${mo} ${s.getDate()} – ${e.getDate()}`;
|
||||
}
|
||||
function formatTokens(n) {
|
||||
if (!n) return '';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return Math.round(n / 1000) + 'k';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function openRecap(filename) {
|
||||
router.push(`/recap/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
|
||||
const generateOptions = [
|
||||
{ key: 'this-week', label: 'This week' },
|
||||
{ key: 'last-week', label: 'Last week' },
|
||||
{ key: 'this-month', label: 'This month' },
|
||||
{ key: 'last-month', label: 'Last month' },
|
||||
];
|
||||
const CMDS = {
|
||||
'this-week': '/obelisk recap this week',
|
||||
'last-week': '/obelisk recap last week',
|
||||
'this-month': '/obelisk recap this month',
|
||||
'last-month': '/obelisk recap last month',
|
||||
};
|
||||
const generateWindow = ref('this-week');
|
||||
const generateCmd = computed(() => CMDS[generateWindow.value]);
|
||||
const cmdCopied = ref(false);
|
||||
async function copyCmd() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generateCmd.value);
|
||||
cmdCopied.value = true;
|
||||
setTimeout(() => { cmdCopied.value = false; }, 1600);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadRecaps() {
|
||||
if (!window.obelisk?.recapList) return;
|
||||
const files = await window.obelisk.recapList();
|
||||
const results = [];
|
||||
for (const f of files) {
|
||||
const data = await window.obelisk.recapRead(f);
|
||||
if (data?.cards) results.push({ ...data, _filename: f });
|
||||
}
|
||||
recaps.value = results;
|
||||
}
|
||||
|
||||
let unsub;
|
||||
onMounted(async () => {
|
||||
await loadRecaps();
|
||||
if (window.obelisk?.onRecapUpdated) {
|
||||
unsub = window.obelisk.onRecapUpdated(() => loadRecaps());
|
||||
}
|
||||
});
|
||||
onUnmounted(() => { unsub?.(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recap-list">
|
||||
<div class="content-wrap">
|
||||
<div class="content" v-if="filtered.length">
|
||||
<section v-for="[year, items] in byYear" :key="year" class="tl-section">
|
||||
<div class="tl-section-head">
|
||||
<span class="year">{{ year }}</span>
|
||||
<span class="span">{{ items.length }} {{ items.length === 1 ? 'recap' : 'recaps' }}</span>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="r in items" :key="r._filename"
|
||||
class="recap-row"
|
||||
:style="{ '--node-glow': glowColor(r.persona?.archetype) }"
|
||||
@click="openRecap(r._filename)"
|
||||
>
|
||||
<div class="recap-node" v-html="sealSvg(r.persona?.archetype)"></div>
|
||||
<div class="recap-card">
|
||||
<div class="recap-body">
|
||||
<div class="recap-period">
|
||||
<span>{{ r.period?.label }}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{{ formatDateRange(r) }}</span>
|
||||
</div>
|
||||
<div class="recap-archetype">{{ r.persona?.title }}</div>
|
||||
<div class="recap-subtitle">{{ r.persona?.subtitle }}</div>
|
||||
<div class="recap-stats">
|
||||
<span>{{ r.metrics?.sessions || 0 }} sessions</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ formatTokens(r.metrics?.tokens) }} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recap-right">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 4l4 4-4 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="content empty-content" v-else>
|
||||
<section class="tl-section">
|
||||
<div class="tl-section-head">
|
||||
<span class="year">No {{ kind }} recaps yet</span>
|
||||
<span class="span">the timeline is waiting</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-timeline">
|
||||
<div class="empty-row placeholder">
|
||||
<div class="empty-node"></div>
|
||||
<div class="empty-card"></div>
|
||||
</div>
|
||||
<div class="empty-row placeholder">
|
||||
<div class="empty-node"></div>
|
||||
<div class="empty-card"></div>
|
||||
</div>
|
||||
<div class="empty-row">
|
||||
<div class="empty-node"></div>
|
||||
<div class="empty-cta">
|
||||
<div class="empty-eyebrow">
|
||||
<span class="diamond"></span>
|
||||
<span>Nothing carved yet</span>
|
||||
</div>
|
||||
<div class="empty-title">A recap is something you carve at the end of a stretch of work.</div>
|
||||
<div class="empty-body">
|
||||
Obelisk doesn't generate one for you automatically — it waits until you ask. Run <code>/obelisk recap this week</code> in Claude Code, and the result will land here as the first marker on this line.
|
||||
</div>
|
||||
<div class="empty-actions">
|
||||
<button class="toolbar-action primary" @click="showGenerate = true">
|
||||
<span class="plus">+</span>
|
||||
<span>Generate {{ kind }} recap</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-row placeholder">
|
||||
<div class="empty-node"></div>
|
||||
<div class="empty-card"></div>
|
||||
</div>
|
||||
<div class="empty-row placeholder">
|
||||
<div class="empty-node"></div>
|
||||
<div class="empty-card"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate modal -->
|
||||
<div class="modal-backdrop" v-if="showGenerate" @click.self="showGenerate = false">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span class="diamond"></span>
|
||||
<span class="title">Generate a new recap</span>
|
||||
<button class="modal-close" @click="showGenerate = false">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<path d="M3 3l6 6M9 3l-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Recaps are generated by Claude Code. Run the command below in your terminal — Obelisk will pick it up automatically.</p>
|
||||
<div class="modal-options">
|
||||
<button
|
||||
v-for="opt in generateOptions" :key="opt.key"
|
||||
class="modal-option" :class="{ active: generateWindow === opt.key }"
|
||||
@click="generateWindow = opt.key"
|
||||
>
|
||||
<span class="modal-option-radio"></span>
|
||||
<span class="modal-option-label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cmd-block">
|
||||
<code><span class="prompt">$</span> {{ generateCmd }}</code>
|
||||
<button class="cmd-copy" :class="{ copied: cmdCopied }" @click="copyCmd">
|
||||
<svg v-if="!cmdCopied" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="9" height="9" rx="1.5"/>
|
||||
<path d="M5 3V2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-1"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8l3 3 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-hint">Generation takes ~30s. New recaps appear in this list automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recap-list {
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
|
||||
--font-serif: 'Iowan Old Style', 'Charter', 'Source Serif Pro', Georgia, serif;
|
||||
--bg: #0a0b14;
|
||||
--hairline: rgba(255,255,255,0.05);
|
||||
--hairline-strong: rgba(255,255,255,0.10);
|
||||
--hairline-vivid: rgba(255,255,255,0.16);
|
||||
--surface: rgba(255,255,255,0.03);
|
||||
--surface-strong: rgba(255,255,255,0.06);
|
||||
--fg: rgba(255,255,255,0.94);
|
||||
--fg-2: rgba(255,255,255,0.74);
|
||||
--fg-3: rgba(255,255,255,0.55);
|
||||
--muted: rgba(255,255,255,0.48);
|
||||
--muted-2: rgba(255,255,255,0.28);
|
||||
--muted-3: rgba(255,255,255,0.16);
|
||||
flex: 1; display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
|
||||
.content-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.content { max-width: 720px; margin: 0 auto; padding: 32px 32px 80px; }
|
||||
|
||||
.tl-section { margin-bottom: 36px; }
|
||||
.tl-section:last-child { margin-bottom: 0; }
|
||||
.tl-section-head {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
margin-bottom: 20px; padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.tl-section-head .year {
|
||||
font-family: var(--font-serif); font-size: 22px;
|
||||
font-weight: 500; color: var(--fg-2); letter-spacing: -0.005em;
|
||||
}
|
||||
.tl-section-head .span {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--muted); letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.timeline { position: relative; }
|
||||
.timeline::before {
|
||||
content: ''; position: absolute;
|
||||
left: 15px; top: 15px; bottom: 15px;
|
||||
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%,
|
||||
rgba(255,255,255,0.12) 30%, rgba(255,255,255,0.06) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.recap-row {
|
||||
position: relative; display: grid;
|
||||
grid-template-columns: 30px 1fr;
|
||||
column-gap: 28px; align-items: center;
|
||||
padding: 12px 0; cursor: pointer;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.recap-row:hover { transform: translateX(2px); }
|
||||
|
||||
.recap-node {
|
||||
width: 30px; height: 30px;
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
.recap-node::before {
|
||||
content: ''; position: absolute; inset: -3px;
|
||||
border-radius: 50%; background: var(--bg); z-index: -1;
|
||||
}
|
||||
.recap-node :deep(svg) {
|
||||
width: 100%; height: 100%; display: block;
|
||||
filter: drop-shadow(0 0 6px var(--node-glow, rgba(167,139,250,0.3)));
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.recap-row:hover .recap-node :deep(svg) {
|
||||
filter: drop-shadow(0 0 10px var(--node-glow, rgba(167,139,250,0.5)));
|
||||
}
|
||||
|
||||
.recap-card {
|
||||
display: grid; grid-template-columns: 1fr auto;
|
||||
gap: 16px; align-items: center;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--hairline); border-radius: 8px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.recap-row:hover .recap-card {
|
||||
background: rgba(255,255,255,0.035);
|
||||
border-color: var(--hairline-strong);
|
||||
}
|
||||
|
||||
.recap-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.recap-period {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--muted); letter-spacing: 0.02em;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.recap-period .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; }
|
||||
.recap-archetype {
|
||||
font-family: var(--font-serif); font-size: 20px;
|
||||
font-weight: 500; color: var(--fg); letter-spacing: -0.01em;
|
||||
}
|
||||
.recap-subtitle {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 14.5px; color: var(--fg-3); line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.recap-stats {
|
||||
margin-top: 4px; font-family: var(--font-mono);
|
||||
font-size: 11.5px; color: var(--muted-2);
|
||||
font-variant-numeric: tabular-nums; letter-spacing: 0.02em;
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
.recap-stats .sep { color: var(--muted-3); }
|
||||
|
||||
.recap-right {
|
||||
display: flex; align-items: center; flex-shrink: 0;
|
||||
color: var(--muted-2); transition: color 0.12s;
|
||||
}
|
||||
.recap-row:hover .recap-right { color: var(--fg-3); }
|
||||
.recap-right svg { width: 14px; height: 14px; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-content { padding-top: 32px; }
|
||||
.empty-timeline { position: relative; padding-top: 8px; }
|
||||
.empty-timeline::before {
|
||||
content: ''; position: absolute;
|
||||
left: 15px; top: 24px; bottom: 24px;
|
||||
width: 1px; margin-left: -0.5px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom, var(--muted-3) 0px, var(--muted-3) 3px,
|
||||
transparent 3px, transparent 7px);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.empty-row {
|
||||
display: grid; grid-template-columns: 30px 1fr;
|
||||
column-gap: 28px; align-items: center; padding: 14px 0;
|
||||
}
|
||||
.empty-node {
|
||||
width: 30px; height: 30px; position: relative; z-index: 2;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.empty-node::before {
|
||||
content: ''; position: absolute; inset: -3px;
|
||||
border-radius: 50%; background: var(--bg); z-index: -1;
|
||||
}
|
||||
.empty-node::after {
|
||||
content: ''; width: 10px; height: 10px;
|
||||
border: 1.5px dashed var(--muted-2);
|
||||
transform: rotate(45deg); border-radius: 1px;
|
||||
}
|
||||
.empty-row.placeholder .empty-card {
|
||||
height: 12px; background: transparent;
|
||||
border: 1px dashed var(--muted-3); border-radius: 6px; opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-cta {
|
||||
padding: 28px 22px;
|
||||
border: 1px dashed var(--hairline-strong); border-radius: 10px;
|
||||
background: rgba(255,255,255,0.015);
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.empty-eyebrow {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
letter-spacing: 0.06em; color: var(--muted);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.empty-eyebrow .diamond {
|
||||
width: 6px; height: 6px; background: var(--muted-2);
|
||||
transform: rotate(45deg); flex-shrink: 0;
|
||||
}
|
||||
.empty-title {
|
||||
font-family: var(--font-serif); font-size: 26px;
|
||||
font-weight: 500; color: var(--fg);
|
||||
letter-spacing: -0.015em; line-height: 1.3; max-width: 460px;
|
||||
}
|
||||
.empty-body {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 15px; color: var(--fg-3); line-height: 1.6; max-width: 460px;
|
||||
}
|
||||
.empty-body code {
|
||||
font-family: var(--font-mono); font-style: normal;
|
||||
font-size: 13px; color: var(--accent-2, #c4b5fd);
|
||||
background: rgba(167,139,250,0.12); padding: 2px 8px;
|
||||
border-radius: 3px; letter-spacing: 0;
|
||||
}
|
||||
.empty-actions { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.empty-actions .toolbar-action { height: 30px; padding: 0 14px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(5, 6, 12, 0.65);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
z-index: 500;
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
width: 100%; max-width: 480px;
|
||||
background: linear-gradient(165deg, rgba(20,22,38,0.95) 0%, rgba(13,15,28,0.95) 100%);
|
||||
border: 1px solid var(--hairline-strong); border-radius: 12px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 12px 32px rgba(0,0,0,0.4),
|
||||
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-head {
|
||||
padding: 18px 22px 12px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
}
|
||||
.modal-head .diamond {
|
||||
width: 6px; height: 6px; background: #a78bfa;
|
||||
transform: rotate(45deg); box-shadow: 0 0 8px rgba(167,139,250,0.35);
|
||||
flex-shrink: 0; align-self: center;
|
||||
}
|
||||
.modal-head .title {
|
||||
font-family: var(--font-serif); font-size: 17px;
|
||||
font-weight: 500; color: var(--fg); flex: 1;
|
||||
}
|
||||
.modal-close {
|
||||
color: var(--muted); width: 24px; height: 24px;
|
||||
display: grid; place-items: center; border-radius: 4px;
|
||||
border: none; background: none; cursor: pointer; transition: all 0.1s;
|
||||
}
|
||||
.modal-close:hover { color: var(--fg-2); background: var(--surface); }
|
||||
.modal-close svg { width: 12px; height: 12px; }
|
||||
|
||||
.modal-body { padding: 18px 22px 20px; }
|
||||
.modal-body p {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 13.5px; color: var(--fg-2); line-height: 1.6; margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-options {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
background: var(--hairline); border: 1px solid var(--hairline);
|
||||
border-radius: 6px; overflow: hidden; margin-bottom: 14px;
|
||||
}
|
||||
.modal-option {
|
||||
padding: 10px 14px; background: rgba(0,0,0,0.2);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
cursor: pointer; border: none; color: inherit; width: 100%; text-align: left;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
.modal-option:hover { background: rgba(255,255,255,0.025); }
|
||||
.modal-option.active { background: rgba(167,139,250,0.12); }
|
||||
.modal-option-label {
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
color: var(--fg-2); flex: 1;
|
||||
}
|
||||
.modal-option.active .modal-option-label { color: #c4b5fd; }
|
||||
.modal-option-radio {
|
||||
width: 12px; height: 12px;
|
||||
border: 1.5px solid var(--muted-2); border-radius: 50%;
|
||||
position: relative; flex-shrink: 0; transition: all 0.1s;
|
||||
}
|
||||
.modal-option.active .modal-option-radio { border-color: #a78bfa; }
|
||||
.modal-option.active .modal-option-radio::after {
|
||||
content: ''; position: absolute; inset: 2px;
|
||||
background: #a78bfa; border-radius: 50%;
|
||||
box-shadow: 0 0 6px rgba(167,139,250,0.35);
|
||||
}
|
||||
|
||||
.cmd-block {
|
||||
position: relative;
|
||||
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline-strong);
|
||||
border-radius: 6px; padding: 14px 50px 14px 16px; margin-bottom: 14px;
|
||||
}
|
||||
.cmd-block code {
|
||||
font-family: var(--font-mono); font-size: 12.5px;
|
||||
color: var(--fg); letter-spacing: 0.005em; word-break: break-all;
|
||||
}
|
||||
.cmd-block code .prompt { color: #c4b5fd; margin-right: 4px; }
|
||||
.cmd-copy {
|
||||
position: absolute; top: 50%; right: 8px; transform: translateY(-50%);
|
||||
width: 32px; height: 32px; display: grid; place-items: center;
|
||||
color: var(--muted); border-radius: 5px; border: none; background: none;
|
||||
cursor: pointer; transition: all 0.1s;
|
||||
}
|
||||
.cmd-copy:hover { color: var(--fg); background: var(--surface); }
|
||||
.cmd-copy.copied { color: #c4b5fd; background: rgba(167,139,250,0.12); }
|
||||
.cmd-copy svg { width: 14px; height: 14px; }
|
||||
|
||||
.modal-hint {
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
color: var(--muted-2); letter-spacing: 0.02em; line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, onActivated, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, onActivated, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state, FOLDER_SVG } from '../store.js';
|
||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||
@@ -21,23 +21,67 @@ const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||
const messages = ref([]);
|
||||
const loading = ref(false);
|
||||
const progressPct = ref(0);
|
||||
const showBackToTop = ref(false);
|
||||
|
||||
// DOM refs
|
||||
const wrapRef = ref(null);
|
||||
const detailRef = ref(null);
|
||||
|
||||
// --- Load session on mount or when id changes ---
|
||||
const FONT_SIZE_KEY = 'obelisk:session-font-size';
|
||||
const FONT_SIZES = [12, 13, 14, 15, 16, 18];
|
||||
const fontSizeIdx = ref(FONT_SIZES.indexOf(parseInt(localStorage.getItem(FONT_SIZE_KEY)) || 14));
|
||||
if (fontSizeIdx.value < 0) fontSizeIdx.value = 2;
|
||||
const fontSize = computed(() => FONT_SIZES[fontSizeIdx.value] + 'px');
|
||||
|
||||
function adjustFont(delta) {
|
||||
const next = fontSizeIdx.value + delta;
|
||||
if (next >= 0 && next < FONT_SIZES.length) {
|
||||
fontSizeIdx.value = next;
|
||||
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[next]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoom(e) {
|
||||
if (!(e.metaKey || e.ctrlKey)) return;
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
if (fontSizeIdx.value < FONT_SIZES.length - 1) fontSizeIdx.value++;
|
||||
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
if (fontSizeIdx.value > 0) fontSizeIdx.value--;
|
||||
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
fontSizeIdx.value = 2;
|
||||
localStorage.setItem(FONT_SIZE_KEY, FONT_SIZES[fontSizeIdx.value]);
|
||||
}
|
||||
}
|
||||
|
||||
const HINT_KEY = 'obelisk:font-hint-shown';
|
||||
const showFontHint = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', handleZoom);
|
||||
if (!localStorage.getItem(HINT_KEY)) {
|
||||
showFontHint.value = true;
|
||||
localStorage.setItem(HINT_KEY, '1');
|
||||
setTimeout(() => { showFontHint.value = false; }, 4000);
|
||||
}
|
||||
await loadMessages();
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
window.addEventListener('keydown', handleZoom);
|
||||
if (messages.value.length === 0 && props.id) {
|
||||
await loadMessages();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleZoom);
|
||||
});
|
||||
|
||||
watch(() => props.id, async (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
messages.value = [];
|
||||
@@ -74,23 +118,37 @@ async function loadMessages() {
|
||||
}
|
||||
|
||||
// --- Scroll / progress tracking ---
|
||||
const currentMsgIdx = ref(0);
|
||||
const totalMsgs = ref(0);
|
||||
|
||||
function onScroll() {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card');
|
||||
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;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||
else break;
|
||||
}
|
||||
currentMsgIdx.value = topMsgIdx;
|
||||
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||
progressPct.value = pct;
|
||||
showBackToTop.value = wrapRef.value.scrollTop > 300;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
function navTo(target) {
|
||||
if (!wrapRef.value || !detailRef.value) return;
|
||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||
if (!msgs.length) return;
|
||||
let idx;
|
||||
if (target === 'first') idx = 0;
|
||||
else if (target === 'last') idx = msgs.length - 1;
|
||||
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' });
|
||||
}
|
||||
|
||||
// --- Toggle helpers ---
|
||||
@@ -147,13 +205,303 @@ function getArgPreview(tc) {
|
||||
if (j.file_path) return j.file_path;
|
||||
if (j.command) return j.command;
|
||||
if (j.path) return j.path;
|
||||
if (j.query) return j.query;
|
||||
if (j.description) return j.description;
|
||||
return JSON.stringify(j).slice(0, 100);
|
||||
if (j.pattern) return j.pattern;
|
||||
if (j.url) return j.url;
|
||||
if (j.name) return j.name;
|
||||
if (j.title) return j.title;
|
||||
for (const k of Object.keys(j)) {
|
||||
if (typeof j[k] === 'string' && j[k].length < 90) return j[k];
|
||||
}
|
||||
return JSON.stringify(j).slice(0, 90);
|
||||
} catch {
|
||||
return (tc.input_json || '').slice(0, 100);
|
||||
return (tc.input_json || '').slice(0, 90);
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolInput(tc) {
|
||||
try {
|
||||
const j = JSON.parse(tc.input_json || '{}');
|
||||
return JSON.stringify(j, null, 2);
|
||||
} catch {
|
||||
return tc.input_json || '';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeH(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const TOOL_ICONS = {
|
||||
Bash: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.2"/><path d="M5 7l2 1.5-2 1.5M8.5 10.5h2.5"/></svg>',
|
||||
Read: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" 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>',
|
||||
Edit: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" 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"/><path d="M6 10l4-4M6.5 10.5l-1.2 1.4 1.4-1.2"/></svg>',
|
||||
Write: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" 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"/><path d="M6 9.5h4M6 11.5h2.5"/></svg>',
|
||||
};
|
||||
|
||||
function getToolIcon(name) {
|
||||
return TOOL_ICONS[name] || '';
|
||||
}
|
||||
|
||||
function renderPrettyTool(tc) {
|
||||
let args;
|
||||
try { args = JSON.parse(tc.input_json || '{}'); } catch { args = {}; }
|
||||
const result = tc.result || {};
|
||||
const isError = !!result.is_error;
|
||||
const out = result.content || '';
|
||||
|
||||
if (tc.name === 'Read') {
|
||||
const path = args.file_path || args.path || '?';
|
||||
if (!out) return '<div style="color:var(--muted);font-size:11px;font-style:italic;">No content returned.</div>';
|
||||
return renderFileContent(out);
|
||||
}
|
||||
|
||||
if (tc.name === 'Write') {
|
||||
const path = args.file_path || args.path || '?';
|
||||
const header = `<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span class="tool-action-label">Writing</span>
|
||||
<span class="file-ref">${escapeH(path)}</span>
|
||||
</div>`;
|
||||
let content = '';
|
||||
if (args.content) {
|
||||
const lines = args.content.split('\n');
|
||||
const gutter = lines.map((_, i) => i + 1).join('\n');
|
||||
content = `<div class="file-content">
|
||||
<div class="file-content-head"><span class="label">New file</span><span class="meta">${lines.length} lines</span></div>
|
||||
<div class="file-content-body collapsed"><div class="gutter">${gutter}</div><div class="code">${escapeH(args.content)}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
const chip = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
|
||||
return header + content + chip;
|
||||
}
|
||||
|
||||
if (tc.name === 'Edit') {
|
||||
let diff = '';
|
||||
if (args.old_string && args.new_string) diff = renderDiff(args.old_string, args.new_string);
|
||||
const chip = `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
|
||||
return diff + chip;
|
||||
}
|
||||
|
||||
if (tc.name === 'Bash') {
|
||||
const desc = args.description ? `<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px;">${escapeH(args.description)}</div>` : '';
|
||||
return desc + renderTerminal(args.command || '', out, isError);
|
||||
}
|
||||
|
||||
return `<div class="body-section"><div class="body-label">Input</div>${renderFieldGrid(args)}</div>` +
|
||||
(out ? `<div class="body-section" style="margin-top:12px;"><div class="body-label">Output</div>${renderOutput(out, isError)}</div>` : '');
|
||||
}
|
||||
|
||||
function renderFileContent(text) {
|
||||
let lines = text.split('\n');
|
||||
// Detect if content already has line numbers (e.g. " 1\tcode" from cat -n / Read tool)
|
||||
const hasLineNums = lines.length > 1 && lines.slice(0, 5).every(l => /^\s*\d+\t/.test(l) || l === '');
|
||||
let gutter;
|
||||
if (hasLineNums) {
|
||||
const parsed = lines.map(l => {
|
||||
const m = l.match(/^\s*(\d+)\t(.*)$/);
|
||||
return m ? { num: m[1], code: m[2] } : { num: '', code: l };
|
||||
});
|
||||
gutter = parsed.map(p => p.num).join('\n');
|
||||
lines = parsed.map(p => p.code);
|
||||
} else {
|
||||
gutter = lines.map((_, i) => i + 1).join('\n');
|
||||
}
|
||||
const total = lines.length;
|
||||
const collapsed = total > 12;
|
||||
return `<div class="file-content">
|
||||
<div class="file-content-head"><span class="label">File contents</span><span class="meta">${total} lines</span></div>
|
||||
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(lines.join('\n'))}</div></div>
|
||||
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDiff(oldStr, newStr) {
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
let prefix = 0;
|
||||
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix++;
|
||||
let suffix = 0;
|
||||
while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix++;
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < prefix; i++) result.push({ kind: 'context', text: oldLines[i], oldNo: i + 1, newNo: i + 1 });
|
||||
for (let i = prefix; i < oldLines.length - suffix; i++) result.push({ kind: 'del', text: oldLines[i], oldNo: i + 1, newNo: null });
|
||||
for (let i = prefix; i < newLines.length - suffix; i++) result.push({ kind: 'add', text: newLines[i], oldNo: null, newNo: i + 1 });
|
||||
for (let i = 0; i < suffix; i++) {
|
||||
result.push({ kind: 'context', text: oldLines[oldLines.length - suffix + i], oldNo: oldLines.length - suffix + i + 1, newNo: newLines.length - suffix + i + 1 });
|
||||
}
|
||||
|
||||
const adds = result.filter(d => d.kind === 'add').length;
|
||||
const dels = result.filter(d => d.kind === 'del').length;
|
||||
|
||||
const rows = result.map(line => {
|
||||
const oldN = line.oldNo == null ? ' ' : String(line.oldNo);
|
||||
const newN = line.newNo == null ? ' ' : String(line.newNo);
|
||||
return `<div class="diff-gutter ${line.kind}">${oldN.padStart(3)} ${newN.padStart(3)}</div><div class="diff-line ${line.kind}"> ${escapeH(line.text)}</div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="diff-view">
|
||||
<div class="diff-view-head"><span class="label">Diff</span><div class="stats"><span class="stat-add">+${adds}</span><span class="stat-del">−${dels}</span></div></div>
|
||||
<div class="diff-body">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTerminal(command, output, isError) {
|
||||
let formatted = escapeH(output);
|
||||
formatted = formatted.replace(/(✓[^\n]*)/g, '<span style="color:#4ade80">$1</span>');
|
||||
formatted = formatted.replace(/(✗[^\n]*|FAIL[^\n]*|Error:[^\n]*)/g, '<span style="color:#f87171">$1</span>');
|
||||
return `<div class="terminal-view">
|
||||
<div class="terminal-prompt-line"><span class="prompt-marker">$</span><span class="prompt-cmd">${escapeH(command)}</span></div>
|
||||
${output ? `<div class="terminal-divider"></div><div class="terminal-output ${isError ? 'is-error' : ''}">${formatted}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFieldGrid(obj) {
|
||||
const entries = Object.entries(obj);
|
||||
if (!entries.length) return '';
|
||||
const rows = entries.map(([k, v]) => {
|
||||
return `<div class="field-key">${escapeH(k)}</div><div class="field-val">${renderValue(v)}</div>`;
|
||||
}).join('');
|
||||
return `<div class="field-grid">${rows}</div>`;
|
||||
}
|
||||
|
||||
function renderValue(v) {
|
||||
if (v === null || v === undefined) return '<span class="literal-null">null</span>';
|
||||
if (typeof v === 'boolean') return `<span class="literal-bool">${v}</span>`;
|
||||
if (typeof v === 'number') return `<span class="literal-num">${v}</span>`;
|
||||
if (typeof v === 'string') {
|
||||
if (/^https?:\/\//.test(v)) return `<span class="literal-string">${escapeH(v)}</span>`;
|
||||
if (v.length > 120) {
|
||||
return `<span class="lit-string-long" onclick="this.classList.toggle('open')">"${escapeH(v.slice(0, 120))}<span class="long-rest">${escapeH(v.slice(120))}</span>"<button class="more-btn">+${v.length - 120}</button></span>`;
|
||||
}
|
||||
return `<span class="literal-string">"${escapeH(v)}"</span>`;
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return '<span class="literal-null">[]</span>';
|
||||
if (v.length <= 4 && v.every(x => typeof x !== 'object')) return `<span class="literal-string">[${v.map(x => renderValue(x)).join(', ')}]</span>`;
|
||||
return `<span class="literal-null">Array(${v.length})</span>`;
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const keys = Object.keys(v);
|
||||
return `<span class="literal-null">Object(${keys.length})</span>`;
|
||||
}
|
||||
return `<span>${escapeH(String(v))}</span>`;
|
||||
}
|
||||
|
||||
function renderOutput(out, isError) {
|
||||
if (!out) return '<div style="padding:8px;color:var(--muted-2);font-style:italic;font-size:11px;">No output.</div>';
|
||||
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(out); } catch {}
|
||||
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every(x => x && typeof x === 'object' && !Array.isArray(x))) {
|
||||
return renderAutoTable(parsed);
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return renderFieldGrid(Object.fromEntries(parsed.map((x, i) => [i, x])));
|
||||
}
|
||||
return renderObjectOutput(parsed);
|
||||
}
|
||||
|
||||
if (out.includes('\n')) {
|
||||
const lines = out.split('\n');
|
||||
const total = lines.length;
|
||||
const collapsed = total > 10;
|
||||
const gutter = lines.map((_, i) => i + 1).join('\n');
|
||||
return `<div class="file-content">
|
||||
<div class="file-content-body ${collapsed ? 'collapsed' : ''}"><div class="gutter">${gutter}</div><div class="code">${escapeH(out)}</div></div>
|
||||
${collapsed ? `<button class="file-content-expand" onclick="this.previousElementSibling.classList.toggle('collapsed');this.textContent=this.previousElementSibling.classList.contains('collapsed')?'Show all ${total} lines':'Collapse'">Show all ${total} lines</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `<div class="result-chip ${isError ? 'error' : ''}">${escapeH(out)}</div>`;
|
||||
}
|
||||
|
||||
function renderObjectOutput(obj) {
|
||||
const hero = extractHero(obj);
|
||||
let rest = obj;
|
||||
if (hero) {
|
||||
rest = { ...obj };
|
||||
if (hero.titleKey) delete rest[hero.titleKey];
|
||||
if (hero.urlKey) delete rest[hero.urlKey];
|
||||
if (hero.idKey) delete rest[hero.idKey];
|
||||
}
|
||||
let html = '';
|
||||
if (hero) {
|
||||
html += `<div style="margin-bottom:10px;padding:8px 12px;border-left:2px solid var(--accent-soft);background:rgba(167,139,250,0.04);border-radius:0 5px 5px 0;">`;
|
||||
if (hero.titleKey) html += `<div style="font-size:14px;font-weight:600;color:var(--fg);margin-bottom:2px;">${escapeH(obj[hero.titleKey])}</div>`;
|
||||
const sub = [];
|
||||
if (hero.idKey) sub.push(escapeH(obj[hero.idKey]));
|
||||
if (hero.urlKey) sub.push(escapeH(obj[hero.urlKey]));
|
||||
if (sub.length) html += `<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);">${sub.join(' · ')}</div>`;
|
||||
html += '</div>';
|
||||
}
|
||||
if (Object.keys(rest).length) html += renderFieldGrid(rest);
|
||||
return html;
|
||||
}
|
||||
|
||||
function extractHero(obj) {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
const titleKey = ['title', 'name', 'summary'].find(k => typeof obj[k] === 'string');
|
||||
const urlKey = ['url', 'permalink', 'href', 'link'].find(k => typeof obj[k] === 'string' && /^https?:/.test(obj[k]));
|
||||
const idKey = ['id', 'identifier', 'uuid', 'key'].find(k => typeof obj[k] === 'string');
|
||||
if (!titleKey && !urlKey && !idKey) return null;
|
||||
return { titleKey, urlKey, idKey };
|
||||
}
|
||||
|
||||
function renderAutoTable(rows) {
|
||||
const sample = rows.slice(0, 5);
|
||||
const allKeys = new Set();
|
||||
for (const row of sample) Object.keys(row).forEach(k => allKeys.add(k));
|
||||
const cols = Array.from(allKeys);
|
||||
const head = cols.map(c => `<th>${escapeH(c)}</th>`).join('');
|
||||
const body = rows.slice(0, 50).map(row =>
|
||||
`<tr>${cols.map(c => {
|
||||
const v = row[c];
|
||||
if (v == null) return '<td><span class="literal-null">—</span></td>';
|
||||
if (typeof v === 'string' && v.length > 60) return `<td title="${escapeH(v)}">${escapeH(v.slice(0, 60))}…</td>`;
|
||||
if (typeof v === 'object') return `<td>${renderValue(v)}</td>`;
|
||||
return `<td>${escapeH(String(v))}</td>`;
|
||||
}).join('')}</tr>`
|
||||
).join('');
|
||||
return `<div class="auto-table-wrap">
|
||||
<div class="auto-table-head"><span class="h-label">Result</span><span class="h-meta">${rows.length} items · ${cols.length} columns</span></div>
|
||||
<div class="auto-table-scroll"><table class="auto-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleRaw(event) {
|
||||
const body = event.target.closest('.toolcall-body');
|
||||
if (!body) return;
|
||||
const pretty = body.querySelector('.toolcall-pretty');
|
||||
const raw = body.querySelector('.toolcall-raw');
|
||||
const btn = body.querySelector('.raw-toggle');
|
||||
if (!pretty || !raw) return;
|
||||
const showing = raw.classList.toggle('show');
|
||||
pretty.classList.toggle('hidden', showing);
|
||||
btn?.classList.toggle('active', showing);
|
||||
}
|
||||
|
||||
function getSkillMd(skillMsgIdx) {
|
||||
const msg = messages.value[skillMsgIdx];
|
||||
if (msg?._skillMd) return msg._skillMd;
|
||||
// Fallback: search next few messages
|
||||
for (let i = skillMsgIdx + 1; i < Math.min(skillMsgIdx + 3, messages.value.length); i++) {
|
||||
const m = messages.value[i];
|
||||
if (m.is_meta === 1 && m.text && m.text.includes('Base directory for this skill')) {
|
||||
return m.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleSkillMd(event) {
|
||||
const card = event.target.closest('.skill-card');
|
||||
if (card) card.classList.toggle('skill-md-open');
|
||||
}
|
||||
|
||||
function getToolCallParsedInput(tc) {
|
||||
try {
|
||||
return JSON.parse(tc.input_json || '{}');
|
||||
@@ -164,7 +512,7 @@ function getToolCallParsedInput(tc) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll">
|
||||
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll" :style="{ '--text-base': fontSize, '--text-md': fontSize }">
|
||||
<div class="detail" ref="detailRef">
|
||||
<!-- Progress bar -->
|
||||
<div class="session-progress">
|
||||
@@ -275,17 +623,26 @@ function getToolCallParsedInput(tc) {
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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 v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||
<span class="tool-name">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && 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 class="toolcall-body-strip">
|
||||
<span class="strip-label">{{ tc.name }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
||||
</div>
|
||||
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
|
||||
<div class="toolcall-raw">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ formatToolInput(tc) }}</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>
|
||||
@@ -295,6 +652,29 @@ function getToolCallParsedInput(tc) {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Skill card (standalone, like workflow) -->
|
||||
<template v-else-if="msg.type === 'assistant' && (msg.tool_calls || []).length === 1 && msg.tool_calls[0].name === 'Skill' && !msg.text">
|
||||
<div class="skill-card" :data-uuid="msg.uuid">
|
||||
<div class="skill-card-icon">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 6.5h6M5 9h4"/></svg>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<div class="skill-card-header">
|
||||
<span class="skill-card-badge">Skill</span>
|
||||
<span class="skill-card-name">{{ getToolCallParsedInput(msg.tool_calls[0]).skill || '?' }}</span>
|
||||
</div>
|
||||
<div class="skill-card-args">{{ getToolCallParsedInput(msg.tool_calls[0]).args || '' }}</div>
|
||||
<div v-if="getSkillMd(idx)" class="skill-card-md">
|
||||
<button class="skill-md-toggle" @click="toggleSkillMd">
|
||||
<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>SKILL.md</span>
|
||||
</button>
|
||||
<div class="skill-md-body" v-html="renderMarkdown(getSkillMd(idx), { variant: 'compact' })"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Standalone thinking message -->
|
||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
||||
@@ -347,8 +727,16 @@ function getToolCallParsedInput(tc) {
|
||||
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
|
||||
<template v-for="tc in msg.tool_calls" :key="tc.id">
|
||||
|
||||
<!-- Skill loaded — agent equipped a capability -->
|
||||
<template v-if="tc.name === 'Skill'">
|
||||
<div class="skill-badge">
|
||||
<span class="skill-label">skill</span>
|
||||
<span class="skill-name">{{ getToolCallParsedInput(tc).skill || '?' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Agent/Task tool call (subagent) -->
|
||||
<template v-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||
<template v-else-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||
<div class="msg-tool agent-call">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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>
|
||||
@@ -361,7 +749,7 @@ function getToolCallParsedInput(tc) {
|
||||
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
|
||||
>View conversation →</button>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="toolcall-body" style="padding:10px 12px;">
|
||||
<template v-if="getToolCallParsedInput(tc).prompt">
|
||||
<div class="tc-section">Prompt</div>
|
||||
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
|
||||
@@ -388,7 +776,7 @@ function getToolCallParsedInput(tc) {
|
||||
>{{ tc.workflow.status }}</span>
|
||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||
</button>
|
||||
<div class="toolcall-body">
|
||||
<div class="toolcall-body" style="padding:10px 12px;">
|
||||
<template v-if="tc.workflow?.agents?.length">
|
||||
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||
<div class="workflow-agent-list">
|
||||
@@ -427,17 +815,26 @@ function getToolCallParsedInput(tc) {
|
||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||
<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 v-if="getToolIcon(tc.name)" class="tool-icon" v-html="getToolIcon(tc.name)"></span>
|
||||
<span class="tool-name">{{ tc.name }}</span>
|
||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||
<span v-if="tc.result && 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 class="toolcall-body-strip">
|
||||
<span class="strip-label">{{ tc.name }}</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
||||
</div>
|
||||
<div class="toolcall-pretty" v-html="renderPrettyTool(tc)"></div>
|
||||
<div class="toolcall-raw">
|
||||
<div class="tc-section">Input</div>
|
||||
<pre>{{ formatToolInput(tc) }}</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>
|
||||
@@ -460,16 +857,30 @@ function getToolCallParsedInput(tc) {
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Back to top button -->
|
||||
<button
|
||||
class="back-to-top"
|
||||
:class="{ show: showBackToTop }"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>
|
||||
<!-- Pagination nav -->
|
||||
<div class="msg-nav" v-if="totalMsgs > 0">
|
||||
<button class="msg-nav-btn" @click="navTo('first')" :disabled="currentMsgIdx === 0" title="First">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v8M7 8l4-4v8z"/></svg>
|
||||
</button>
|
||||
<button class="msg-nav-btn" @click="navTo('prev')" :disabled="currentMsgIdx === 0" title="Previous">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
|
||||
</button>
|
||||
<span class="msg-nav-pos"><span class="msg-nav-current">{{ currentMsgIdx + 1 }}</span> / {{ totalMsgs }}</span>
|
||||
<button class="msg-nav-btn" @click="navTo('next')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Next">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4l4 4-4 4"/></svg>
|
||||
</button>
|
||||
<button class="msg-nav-btn" @click="navTo('last')" :disabled="currentMsgIdx >= totalMsgs - 1" title="Last">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v8M9 8l-4-4v8z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="toast">
|
||||
<div v-if="showFontHint" class="font-toast">
|
||||
⌘ +/- to adjust font size
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -478,5 +889,26 @@ function getToolCallParsedInput(tc) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.font-toast {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--fg-2);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
.toast-enter-active { transition: opacity 0.3s, transform 0.3s; }
|
||||
.toast-leave-active { transition: opacity 0.6s, transform 0.6s; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user