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:
tommy0103
2026-06-14 03:16:49 +08:00
parent 4eec6b38c9
commit 9fc7f202f0
30 changed files with 4924 additions and 76 deletions
+1 -1
View File
@@ -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">
+305
View File
@@ -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>
+93
View File
@@ -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>
+512
View File
@@ -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>
+463 -31
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 &rarr;</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 &middot; {{ 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>