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:
@@ -3,3 +3,4 @@ plans/
|
|||||||
.skillopt-backups
|
.skillopt-backups
|
||||||
tests/
|
tests/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
dist-renderer/
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ same SQLite data.
|
|||||||
- `references/schema.md` — full SQLite schema and API reference
|
- `references/schema.md` — full SQLite schema and API reference
|
||||||
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
|
- `references/query-patterns.md` — copyable CodeAct recipes for common retrieval tasks
|
||||||
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
|
- `references/retrieval-semantics.md` — query design frame for scoped and synthesis retrieval
|
||||||
|
- `references/recap-patterns.md` — optional `/obelisk recap` card content contract
|
||||||
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
|
- `references/pitfalls.md` — scope, FTS, ordering, compact/raw, and field-name traps
|
||||||
|
|
||||||
The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md`
|
The executable SQLite schema lives in `scripts/schema.sql`; `references/schema.md`
|
||||||
@@ -132,6 +133,8 @@ is the human/agent explanation of that contract.
|
|||||||
The design is progressive disclosure with guardrails: the main skill keeps the
|
The design is progressive disclosure with guardrails: the main skill keeps the
|
||||||
core contract and high-risk pitfalls visible, while longer recipes and the full
|
core contract and high-risk pitfalls visible, while longer recipes and the full
|
||||||
schema stay out of the first prompt until the agent needs them.
|
schema stay out of the first prompt until the agent needs them.
|
||||||
|
The optional recap reference is only for the explicit `/obelisk recap` intent;
|
||||||
|
it is not part of the ordinary retrieval path.
|
||||||
|
|
||||||
## What gets indexed
|
## What gets indexed
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ Full-text search via FTS5 covers message text across every session layer and ran
|
|||||||
├── schema.md # Full table schema + advanced API reference
|
├── schema.md # Full table schema + advanced API reference
|
||||||
├── query-patterns.md # Copyable retrieval recipes
|
├── query-patterns.md # Copyable retrieval recipes
|
||||||
├── retrieval-semantics.md # Query design frame for retrieval semantics
|
├── retrieval-semantics.md # Query design frame for retrieval semantics
|
||||||
|
├── recap-patterns.md # Optional /obelisk recap card content contract
|
||||||
└── pitfalls.md # Scope, FTS, ordering, and compactness traps
|
└── pitfalls.md # Scope, FTS, ordering, and compactness traps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -72,12 +72,36 @@ Use `sql()` only as an escalation path for exact joins, aggregations, or schema
|
|||||||
questions that helpers cannot express cleanly. Do not use raw SQL as a generic
|
questions that helpers cannot express cleanly. Do not use raw SQL as a generic
|
||||||
fallback for broad retrieval.
|
fallback for broad retrieval.
|
||||||
|
|
||||||
|
## Intent Routing
|
||||||
|
|
||||||
|
Obelisk supports a small intent prefix layer after `/obelisk`. This is for
|
||||||
|
output intent, not retrieval architecture.
|
||||||
|
|
||||||
|
| Intent | Description | Reference |
|
||||||
|
|---|---|---|
|
||||||
|
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap-patterns.md` |
|
||||||
|
|
||||||
|
Routing rules:
|
||||||
|
|
||||||
|
1. If the first word is `recap`, read `references/recap-patterns.md` before the
|
||||||
|
first query. Everything after `recap` is the recap target.
|
||||||
|
Common app-generated prompts include `/obelisk recap this week`,
|
||||||
|
`/obelisk recap last week`, `/obelisk recap this month`, and
|
||||||
|
`/obelisk recap last month`; interpret these as natural period targets
|
||||||
|
relative to the current date and timezone.
|
||||||
|
2. `recap` does not create a separate retrieval layer. It still uses
|
||||||
|
`overview()`, `memories()`, helpers, and `sql()` only when needed.
|
||||||
|
3. If the first word is not `recap`, do not load
|
||||||
|
`references/recap-patterns.md`. Continue with Query Routing below. Do not
|
||||||
|
infer recap from broad requests for weekly/monthly summaries, charts,
|
||||||
|
rankings, shareable cards, or playlist-style metaphors.
|
||||||
|
|
||||||
## Query Routing
|
## Query Routing
|
||||||
|
|
||||||
Before writing a query, classify the task. Progressive disclosure is useful, but
|
Before writing a query, classify the task. Progressive disclosure is useful, but
|
||||||
skipping the relevant reference usually costs extra query rounds.
|
skipping the relevant reference usually costs extra query rounds.
|
||||||
|
|
||||||
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
|
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
|
||||||
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
|
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
|
||||||
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
|
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
|
||||||
- Read `references/pitfalls.md` after an error or when helper fields, FTS syntax, aliases, or row shapes are unclear.
|
- Read `references/pitfalls.md` after an error or when helper fields, FTS syntax, aliases, or row shapes are unclear.
|
||||||
|
|||||||
+24
-4
@@ -64,9 +64,27 @@ function createIndexerService({
|
|||||||
let running = false;
|
let running = false;
|
||||||
let pending = false;
|
let pending = false;
|
||||||
let lastReason = null;
|
let lastReason = null;
|
||||||
|
let changedPaths = new Set();
|
||||||
let idlePromise = Promise.resolve();
|
let idlePromise = Promise.resolve();
|
||||||
|
|
||||||
const runBuildNow = (reason = 'manual') => {
|
const addChangedPath = (changedPath) => {
|
||||||
|
if (Array.isArray(changedPath)) {
|
||||||
|
for (const p of changedPath) addChangedPath(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = changedPath ? String(changedPath) : '';
|
||||||
|
if (name) changedPaths.add(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeChangedPaths = () => {
|
||||||
|
if (!changedPaths.size) return undefined;
|
||||||
|
const paths = [...changedPaths];
|
||||||
|
changedPaths = new Set();
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runBuildNow = (reason = 'manual', paths = undefined) => {
|
||||||
|
addChangedPath(paths);
|
||||||
if (stopped) return idlePromise;
|
if (stopped) return idlePromise;
|
||||||
if (running) {
|
if (running) {
|
||||||
pending = true;
|
pending = true;
|
||||||
@@ -74,8 +92,9 @@ function createIndexerService({
|
|||||||
}
|
}
|
||||||
running = true;
|
running = true;
|
||||||
pending = false;
|
pending = false;
|
||||||
|
const buildChangedPaths = takeChangedPaths();
|
||||||
idlePromise = (async () => {
|
idlePromise = (async () => {
|
||||||
await buildIndex({ reason });
|
await buildIndex({ reason, changedPaths: buildChangedPaths });
|
||||||
writeHeartbeat();
|
writeHeartbeat();
|
||||||
})()
|
})()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -91,8 +110,9 @@ function createIndexerService({
|
|||||||
return idlePromise;
|
return idlePromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleBuild = (reason = 'change') => {
|
const scheduleBuild = (reason = 'change', changedPath = undefined) => {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
addChangedPath(changedPath);
|
||||||
lastReason = reason;
|
lastReason = reason;
|
||||||
if (running) pending = true;
|
if (running) pending = true;
|
||||||
if (buildTimer) timers.clearTimeout(buildTimer);
|
if (buildTimer) timers.clearTimeout(buildTimer);
|
||||||
@@ -112,7 +132,7 @@ function createIndexerService({
|
|||||||
|
|
||||||
const startWatching = () => {
|
const startWatching = () => {
|
||||||
if (stopped || watcher) return;
|
if (stopped || watcher) return;
|
||||||
watcher = watch(() => scheduleBuild('watch'));
|
watcher = watch((changedPath) => scheduleBuild('watch', changedPath));
|
||||||
if (!watcher) {
|
if (!watcher) {
|
||||||
watchRetryTimer = timers.setTimeout(() => {
|
watchRetryTimer = timers.setTimeout(() => {
|
||||||
watchRetryTimer = null;
|
watchRetryTimer = null;
|
||||||
|
|||||||
+116
-1
@@ -1,4 +1,4 @@
|
|||||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
const { app, BrowserWindow, ipcMain, clipboard, dialog, nativeImage } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -57,6 +57,13 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prevent Electron's built-in zoom so Cmd+=/- reaches the renderer
|
||||||
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if ((input.meta || input.control) && ['+', '=', '-', '0'].includes(input.key)) {
|
||||||
|
win.webContents.setZoomLevel(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const isDev = process.argv.includes('--dev');
|
const isDev = process.argv.includes('--dev');
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
win.loadURL('http://localhost:5173');
|
win.loadURL('http://localhost:5173');
|
||||||
@@ -66,11 +73,43 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OBELISK_DIR = path.join(os.homedir(), '.obelisk');
|
||||||
|
const RECAP_DIR = path.join(OBELISK_DIR, 'recap');
|
||||||
|
let obeliskWatcher = null;
|
||||||
|
|
||||||
|
function startObeliskWatcher() {
|
||||||
|
const chokidar = require('chokidar');
|
||||||
|
if (!fs.existsSync(OBELISK_DIR)) {
|
||||||
|
fs.mkdirSync(OBELISK_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
obeliskWatcher = chokidar.watch(OBELISK_DIR, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||||
|
ignored: (p, stats) => {
|
||||||
|
if (stats?.isDirectory()) return false;
|
||||||
|
if (!stats) return false;
|
||||||
|
return !p.endsWith('.md') && !p.endsWith('.json');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
obeliskWatcher.on('add', onObeliskChange);
|
||||||
|
obeliskWatcher.on('change', onObeliskChange);
|
||||||
|
obeliskWatcher.on('unlink', onObeliskChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onObeliskChange(filePath) {
|
||||||
|
if (filePath.startsWith(RECAP_DIR)) {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send('obelisk:recap-updated', filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
indexerWorker = createWorkerBuildIndex();
|
indexerWorker = createWorkerBuildIndex();
|
||||||
openDb();
|
openDb();
|
||||||
createWindow();
|
createWindow();
|
||||||
startIndexerService().runBuildNow('startup');
|
startIndexerService().runBuildNow('startup');
|
||||||
|
startObeliskWatcher();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
@@ -80,6 +119,7 @@ app.whenReady().then(() => {
|
|||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
if (indexerService) indexerService.stop();
|
if (indexerService) indexerService.stop();
|
||||||
if (indexerWorker) indexerWorker.stop();
|
if (indexerWorker) indexerWorker.stop();
|
||||||
|
if (obeliskWatcher) obeliskWatcher.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
@@ -300,3 +340,78 @@ ipcMain.handle('db:getUsageStats', () => {
|
|||||||
|
|
||||||
return { daily, totalTokens, peakDay, longestTurn };
|
return { daily, totalTokens, peakDay, longestTurn };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Capture ---
|
||||||
|
|
||||||
|
const EXPORT_WIDTH = 540;
|
||||||
|
const EXPORT_HEIGHT = 675;
|
||||||
|
|
||||||
|
async function createExportCapture(parentWin, query) {
|
||||||
|
const exportWin = new BrowserWindow({
|
||||||
|
width: EXPORT_WIDTH,
|
||||||
|
height: EXPORT_HEIGHT,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
offscreen: true,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDev = process.argv.includes('--dev');
|
||||||
|
const url = isDev
|
||||||
|
? `http://localhost:5173/#/recap-export?${query}`
|
||||||
|
: `file://${path.join(__dirname, 'dist-renderer', 'index.html')}#/recap-export?${query}`;
|
||||||
|
|
||||||
|
await exportWin.loadURL(url);
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
const image = await exportWin.webContents.capturePage({
|
||||||
|
x: 0, y: 0, width: EXPORT_WIDTH, height: EXPORT_HEIGHT,
|
||||||
|
});
|
||||||
|
exportWin.close();
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('capture:export', async (event, { cardIdx, archetype }) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!win) return null;
|
||||||
|
const query = `card=${cardIdx}&arch=${archetype}`;
|
||||||
|
const image = await createExportCapture(win, query);
|
||||||
|
const { filePath } = await dialog.showSaveDialog(win, {
|
||||||
|
defaultPath: `obelisk-recap-${cardIdx + 1}.png`,
|
||||||
|
filters: [{ name: 'PNG', extensions: ['png'] }],
|
||||||
|
});
|
||||||
|
if (!filePath) return null;
|
||||||
|
fs.writeFileSync(filePath, image.toPNG());
|
||||||
|
return filePath;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('capture:copy', async (event, { cardIdx, archetype }) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!win) return false;
|
||||||
|
const query = `card=${cardIdx}&arch=${archetype}`;
|
||||||
|
const image = await createExportCapture(win, query);
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Recap files ---
|
||||||
|
|
||||||
|
ipcMain.handle('recap:list', () => {
|
||||||
|
if (!fs.existsSync(RECAP_DIR)) return [];
|
||||||
|
return fs.readdirSync(RECAP_DIR)
|
||||||
|
.filter(f => f.endsWith('.json'))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('recap:read', (_, filename) => {
|
||||||
|
const filePath = path.join(RECAP_DIR, path.basename(filename));
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch { return null; }
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,4 +24,13 @@ contextBridge.exposeInMainWorld('obelisk', {
|
|||||||
ipcRenderer.on('obelisk:index-updated', listener);
|
ipcRenderer.on('obelisk:index-updated', listener);
|
||||||
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
|
return () => ipcRenderer.removeListener('obelisk:index-updated', listener);
|
||||||
},
|
},
|
||||||
|
captureExport: (opts) => ipcRenderer.invoke('capture:export', opts),
|
||||||
|
copyImage: (opts) => ipcRenderer.invoke('capture:copy', opts),
|
||||||
|
recapList: () => ipcRenderer.invoke('recap:list'),
|
||||||
|
recapRead: (filename) => ipcRenderer.invoke('recap:read', filename),
|
||||||
|
onRecapUpdated: (callback) => {
|
||||||
|
const listener = (_, filePath) => callback(filePath);
|
||||||
|
ipcRenderer.on('obelisk:recap-updated', listener);
|
||||||
|
return () => ipcRenderer.removeListener('obelisk:recap-updated', listener);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch, ref, provide } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import {
|
import {
|
||||||
state,
|
state,
|
||||||
@@ -29,6 +29,7 @@ const currentRouteType = computed(() => {
|
|||||||
const name = route.name;
|
const name = route.name;
|
||||||
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
|
if (name === 'SessionList' || name === 'SessionDetail' || name === 'SubagentDetail') return 'sessions';
|
||||||
if (name === 'Activity') return 'activity';
|
if (name === 'Activity') return 'activity';
|
||||||
|
if (name === 'Recap' || name === 'RecapDetail') return 'recap';
|
||||||
return 'memory';
|
return 'memory';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +82,10 @@ const windowTitle = computed(() => {
|
|||||||
let scopeText = '';
|
let scopeText = '';
|
||||||
if (route.name === 'Activity') {
|
if (route.name === 'Activity') {
|
||||||
scopeText = 'Activity';
|
scopeText = 'Activity';
|
||||||
|
} else if (route.name === 'Recap') {
|
||||||
|
scopeText = 'Recap';
|
||||||
|
} else if (route.name === 'RecapDetail') {
|
||||||
|
scopeText = `Recap · ${route.params.id}`;
|
||||||
} else if (route.name?.startsWith('Session')) {
|
} else if (route.name?.startsWith('Session')) {
|
||||||
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
||||||
const s = state.sessions.find(x => x.id === route.params.id);
|
const s = state.sessions.find(x => x.id === route.params.id);
|
||||||
@@ -114,6 +119,8 @@ function handleSidebarRoute(routeName) {
|
|||||||
router.push('/sessions');
|
router.push('/sessions');
|
||||||
} else if (routeName === 'activity') {
|
} else if (routeName === 'activity') {
|
||||||
router.push('/activity');
|
router.push('/activity');
|
||||||
|
} else if (routeName === 'recap') {
|
||||||
|
router.push('/recap');
|
||||||
} else {
|
} else {
|
||||||
router.push('/memory');
|
router.push('/memory');
|
||||||
}
|
}
|
||||||
@@ -130,8 +137,7 @@ function handleClearProject() {
|
|||||||
|
|
||||||
function handleSidebarProject(slug) {
|
function handleSidebarProject(slug) {
|
||||||
setProject(slug);
|
setProject(slug);
|
||||||
// Stay on current list route
|
if (currentRouteType.value === 'sessions') router.push('/sessions');
|
||||||
if (state.route === 'sessions') router.push('/sessions');
|
|
||||||
else router.push('/memory');
|
else router.push('/memory');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,10 +166,20 @@ function handleToggleSearchMsgs() {
|
|||||||
|
|
||||||
// --- Keep-alive includes ---
|
// --- Keep-alive includes ---
|
||||||
const keepAliveIncludes = ['SessionDetail'];
|
const keepAliveIncludes = ['SessionDetail'];
|
||||||
|
|
||||||
|
const isExportRoute = computed(() => route.name === 'RecapExport');
|
||||||
|
|
||||||
|
// --- Recap ---
|
||||||
|
const recapGenerateOpen = ref(false);
|
||||||
|
function setRecapKind(k) {
|
||||||
|
router.replace({ path: '/recap', query: { kind: k } });
|
||||||
|
}
|
||||||
|
provide('recapGenerateOpen', recapGenerateOpen);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<router-view v-if="isExportRoute" />
|
||||||
|
<div class="app" v-else>
|
||||||
<div class="titlebar">
|
<div class="titlebar">
|
||||||
<div class="titlebar-text" id="titlebar-text">
|
<div class="titlebar-text" id="titlebar-text">
|
||||||
<span class="app-name">{{ windowTitle.appName }}</span>
|
<span class="app-name">{{ windowTitle.appName }}</span>
|
||||||
@@ -216,7 +232,7 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
:class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
||||||
@click="handleSidebarView('active')"
|
@click="handleSidebarView('active')"
|
||||||
>
|
>
|
||||||
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -228,7 +244,7 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="sidebar-item sub"
|
class="sidebar-item sub"
|
||||||
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
:class="{ active: currentRouteType === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
||||||
@click="handleSidebarView('active')"
|
@click="handleSidebarView('active')"
|
||||||
>
|
>
|
||||||
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -239,7 +255,7 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="sidebar-item sub"
|
class="sidebar-item sub"
|
||||||
:class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
|
:class="{ active: currentRouteType === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
|
||||||
@click="handleSidebarView('archived')"
|
@click="handleSidebarView('archived')"
|
||||||
>
|
>
|
||||||
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg class="icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -264,9 +280,20 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="label">Activity</span>
|
<span class="label">Activity</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: route.name === 'Recap' }"
|
||||||
|
@click="handleSidebarRoute('recap')"
|
||||||
|
>
|
||||||
|
<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 2h10v12H3z"/>
|
||||||
|
<path d="M6 5h4M6 8h4M6 11h2"/>
|
||||||
|
</svg>
|
||||||
|
<span class="label">Recap</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section projects">
|
<div class="sidebar-section projects" v-if="currentRouteType === 'sessions' || currentRouteType === 'memory'">
|
||||||
<div class="sidebar-section-title"><span>Projects</span></div>
|
<div class="sidebar-section-title"><span>Projects</span></div>
|
||||||
<div class="sidebar-search" v-if="totalProjectCount >= 6">
|
<div class="sidebar-search" v-if="totalProjectCount >= 6">
|
||||||
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
@@ -346,9 +373,28 @@ const keepAliveIncludes = ['SessionDetail'];
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
|
<span v-if="route.name === 'Activity'" class="crumb terminal">Activity</span>
|
||||||
|
<span v-if="route.name === 'Recap'" class="crumb terminal">Recap</span>
|
||||||
|
<router-link v-if="route.name === 'RecapDetail'" class="crumb" to="/recap">Recap</router-link>
|
||||||
|
<template v-if="route.name === 'RecapDetail'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<span class="crumb terminal">{{ route.params.id }}</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-spacer"></div>
|
<div class="toolbar-spacer"></div>
|
||||||
|
|
||||||
|
<!-- Recap toolbar actions -->
|
||||||
|
<template v-if="route.name === 'Recap'">
|
||||||
|
<div class="tab-group">
|
||||||
|
<button :class="{ active: (route.query.kind || 'weekly') === 'weekly' }" @click="setRecapKind('weekly')">Weekly</button>
|
||||||
|
<button :class="{ active: route.query.kind === 'monthly' }" @click="setRecapKind('monthly')">Monthly</button>
|
||||||
|
</div>
|
||||||
|
<button class="toolbar-action-primary" @click="recapGenerateOpen = true">
|
||||||
|
<span class="plus">+</span>
|
||||||
|
<span>Generate</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="toolbar-search" id="search-wrap" v-if="showToolbar">
|
<div class="toolbar-search" id="search-wrap" v-if="showToolbar">
|
||||||
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
<circle cx="7" cy="7" r="5"/>
|
<circle cx="7" cy="7" r="5"/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,8 @@ const breadcrumbs = computed(() => {
|
|||||||
crumbs.push({ label: filename, terminal: true, filename: true });
|
crumbs.push({ label: filename, terminal: true, filename: true });
|
||||||
} else if (name === 'Activity') {
|
} else if (name === 'Activity') {
|
||||||
crumbs.push({ label: 'Activity', terminal: true });
|
crumbs.push({ label: 'Activity', terminal: true });
|
||||||
|
} else if (name === 'Recap') {
|
||||||
|
crumbs.push({ label: 'Recap', terminal: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return crumbs;
|
return crumbs;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
headline: String,
|
||||||
|
stats: Array,
|
||||||
|
mostSaidPhrase: String,
|
||||||
|
signoff: String,
|
||||||
|
idx: { type: Number, default: 5 },
|
||||||
|
total: { type: Number, default: 5 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="card card-closing">
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="diamond"></span>
|
||||||
|
<span>The week, carved.</span>
|
||||||
|
<span class="eyebrow-spacer"></span>
|
||||||
|
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="closing-body">
|
||||||
|
<div class="closing-headline">{{ headline }}</div>
|
||||||
|
|
||||||
|
<div class="closing-stats" v-if="stats">
|
||||||
|
<div v-for="(line, i) in stats" :key="i">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="closing-quote" v-if="mostSaidPhrase">
|
||||||
|
<span>"{{ mostSaidPhrase }}"</span>
|
||||||
|
<span class="verb">— most-said phrase</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="closing-signoff">{{ signoff }}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './card-base.css';
|
||||||
|
|
||||||
|
.card-closing {
|
||||||
|
background:
|
||||||
|
radial-gradient(80% 70% at 50% 30%, var(--tg-soft) 0%, transparent 60%),
|
||||||
|
radial-gradient(60% 50% at 50% 50%, var(--tg-mid) 0%, transparent 70%),
|
||||||
|
linear-gradient(180deg, rgba(10,11,20,0.6) 0%, rgba(10,11,20,0.95) 100%);
|
||||||
|
transition: background var(--theme-ease);
|
||||||
|
}
|
||||||
|
.closing-body {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 0 40px; gap: 32px;
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.closing-headline {
|
||||||
|
font-family: var(--font-serif); font-size: 72px;
|
||||||
|
line-height: 1; font-weight: 500; letter-spacing: -0.02em;
|
||||||
|
color: var(--fg); text-shadow: 0 4px 24px var(--tg);
|
||||||
|
transition: text-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
.closing-stats {
|
||||||
|
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.closing-quote {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 19px; color: var(--fg-2); line-height: 1.5; max-width: 360px;
|
||||||
|
}
|
||||||
|
.closing-quote .verb {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 13px; color: var(--muted); display: block; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.closing-signoff {
|
||||||
|
font-family: var(--font-serif); font-size: 15px;
|
||||||
|
color: var(--muted); font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { CORNER_SEALS } from './seals.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
archKey: String,
|
||||||
|
badge: String,
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
activity: Array,
|
||||||
|
footer: String,
|
||||||
|
idx: { type: Number, default: 1 },
|
||||||
|
total: { type: Number, default: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const sealSvg = computed(() => CORNER_SEALS[props.archKey] || '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="card card-cover">
|
||||||
|
<div class="cover-stars">
|
||||||
|
<span></span><span></span><span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="diamond"></span>
|
||||||
|
<span>{{ badge }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-seal-corner" v-html="sealSvg"></div>
|
||||||
|
<div class="cover-body">
|
||||||
|
<div class="cover-archetype">{{ title }}</div>
|
||||||
|
<div class="cover-subtitle">{{ subtitle }}</div>
|
||||||
|
|
||||||
|
<div class="cover-activity">
|
||||||
|
<div class="cover-activity-row">
|
||||||
|
<div
|
||||||
|
v-for="(val, i) in activity" :key="i"
|
||||||
|
class="cover-activity-cell"
|
||||||
|
:class="{ dim: val < 0.4 }"
|
||||||
|
>
|
||||||
|
<div v-if="val > 0" class="fill" :style="{ height: val * 100 + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cover-activity-labels">
|
||||||
|
<span>M</span><span>T</span><span>W</span><span>T</span><span>F</span><span>S</span><span>S</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cover-footer" v-html="footer"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './card-base.css';
|
||||||
|
|
||||||
|
.card-cover {
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 50% 100%, var(--tg) 0%, transparent 55%),
|
||||||
|
radial-gradient(100% 70% at 50% 80%, var(--tg-mid) 0%, transparent 65%),
|
||||||
|
radial-gradient(80% 50% at 50% 60%, var(--tg-soft) 0%, transparent 65%),
|
||||||
|
linear-gradient(180deg, rgba(10,11,20,0.4) 0%, rgba(10,11,20,0.85) 70%);
|
||||||
|
transition: background var(--theme-ease);
|
||||||
|
}
|
||||||
|
.cover-stars {
|
||||||
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
.cover-stars span {
|
||||||
|
position: absolute;
|
||||||
|
width: 1.5px; height: 1.5px;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 4px rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.cover-stars span:nth-child(1) { top: 12%; left: 18%; }
|
||||||
|
.cover-stars span:nth-child(2) { top: 8%; left: 78%; width: 2px; height: 2px; }
|
||||||
|
.cover-stars span:nth-child(3) { top: 22%; left: 88%; opacity: 0.6; }
|
||||||
|
.cover-stars span:nth-child(4) { top: 32%; left: 8%; opacity: 0.5; }
|
||||||
|
.cover-stars span:nth-child(5) { top: 18%; left: 52%; width: 1px; height: 1px; opacity: 0.7; }
|
||||||
|
|
||||||
|
.cover-seal-corner {
|
||||||
|
position: absolute;
|
||||||
|
top: 22px; right: 24px;
|
||||||
|
width: 60px; height: 60px; z-index: 3;
|
||||||
|
}
|
||||||
|
.cover-seal-corner :deep(svg) {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
filter: drop-shadow(0 0 12px var(--tg));
|
||||||
|
transition: filter var(--theme-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-body {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
padding: 0 36px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.cover-archetype {
|
||||||
|
margin-top: auto;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 64px; line-height: 1.05; font-weight: 500;
|
||||||
|
letter-spacing: -0.02em; color: var(--fg);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
text-shadow: 0 2px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.cover-subtitle {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 19px; line-height: 1.5; color: var(--fg-2);
|
||||||
|
margin-bottom: 36px; max-width: 92%;
|
||||||
|
}
|
||||||
|
.cover-activity { margin-bottom: 28px; }
|
||||||
|
.cover-activity-row {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 6px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.cover-activity-cell {
|
||||||
|
height: 32px; border-radius: 3px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-activity-cell .fill {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
background: linear-gradient(to top, var(--tc), var(--tc-2));
|
||||||
|
box-shadow: 0 0 10px var(--tg);
|
||||||
|
border-radius: 0 0 2px 2px;
|
||||||
|
transition: background var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
.cover-activity-cell.dim .fill {
|
||||||
|
background: linear-gradient(to top, rgba(255,255,255,0.15), rgba(255,255,255,0.06));
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.cover-activity-labels {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted-2); text-align: center;
|
||||||
|
}
|
||||||
|
.cover-footer {
|
||||||
|
padding-bottom: 28px;
|
||||||
|
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
|
||||||
|
display: flex; gap: 14px; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.cover-footer :deep(strong) { color: var(--fg); font-weight: 500; }
|
||||||
|
.cover-footer :deep(.sep) { color: var(--muted-3); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
items: Array,
|
||||||
|
idx: { type: Number, default: 2 },
|
||||||
|
total: { type: Number, default: 5 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="card" data-themed>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="diamond"></span>
|
||||||
|
<span>Your thinking path</span>
|
||||||
|
<span class="eyebrow-spacer"></span>
|
||||||
|
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ title }}</div>
|
||||||
|
|
||||||
|
<div class="timeline-wrap">
|
||||||
|
<div class="timeline">
|
||||||
|
<div v-for="(item, i) in items" :key="i" class="tl-item">
|
||||||
|
<div class="tl-node"></div>
|
||||||
|
<div class="tl-day">{{ item.day }}</div>
|
||||||
|
<div class="tl-prompt">{{ item.prompt }}</div>
|
||||||
|
<div class="tl-outcome">
|
||||||
|
<span>{{ item.outcome }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './card-base.css';
|
||||||
|
|
||||||
|
.timeline-wrap {
|
||||||
|
flex: 1; padding: 0 36px 24px 36px;
|
||||||
|
overflow-y: auto; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.timeline { position: relative; padding-left: 28px; }
|
||||||
|
.timeline::before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
left: 6px; top: 14px; bottom: 14px; width: 1px;
|
||||||
|
background: linear-gradient(to bottom,
|
||||||
|
var(--tg) 0%, var(--tg-mid) 30%,
|
||||||
|
rgba(255,255,255,0.12) 70%, rgba(255,255,255,0.06) 100%);
|
||||||
|
transition: background var(--theme-ease);
|
||||||
|
}
|
||||||
|
.tl-item { position: relative; padding: 8px 0 10px; }
|
||||||
|
.tl-item:first-child { padding-top: 4px; }
|
||||||
|
.tl-item:last-child { padding-bottom: 0; }
|
||||||
|
|
||||||
|
.tl-node {
|
||||||
|
position: absolute; left: -28px; top: 16px;
|
||||||
|
width: 13px; height: 13px;
|
||||||
|
}
|
||||||
|
.tl-item:first-child .tl-node { top: 12px; }
|
||||||
|
.tl-node::before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
left: 50%; top: 50%;
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
background: var(--tc);
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
box-shadow: 0 0 8px var(--tg), 0 0 0 3px rgba(10,11,20,1);
|
||||||
|
transition: background var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl-day {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; font-weight: 600;
|
||||||
|
color: var(--tc-2); margin-bottom: 4px; letter-spacing: 0.01em;
|
||||||
|
transition: color var(--theme-ease);
|
||||||
|
}
|
||||||
|
.tl-prompt {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 16px; line-height: 1.35; color: var(--fg); margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tl-prompt::before { content: '\201C'; color: var(--muted-2); margin-right: 1px; }
|
||||||
|
.tl-prompt::after { content: '\201D'; color: var(--muted-2); margin-left: 1px; }
|
||||||
|
|
||||||
|
.tl-outcome {
|
||||||
|
display: inline-flex; align-items: baseline; gap: 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; color: var(--fg-2);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-left: 2px solid var(--tc);
|
||||||
|
box-shadow: -2px 0 8px -2px var(--tg-mid);
|
||||||
|
transition: border-left-color var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
observations: Array,
|
||||||
|
meter: Object,
|
||||||
|
quote: Object,
|
||||||
|
idx: { type: Number, default: 3 },
|
||||||
|
total: { type: Number, default: 5 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="card" data-themed>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="diamond"></span>
|
||||||
|
<span>Your vibe this week</span>
|
||||||
|
<span class="eyebrow-spacer"></span>
|
||||||
|
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ title }}</div>
|
||||||
|
|
||||||
|
<div class="vibe-content">
|
||||||
|
<div class="vibe-section">
|
||||||
|
<div class="section-label">Things you kept saying</div>
|
||||||
|
<div class="vibe-observations">
|
||||||
|
<div v-for="(obs, i) in observations" :key="i" class="vibe-obs">
|
||||||
|
<div class="vibe-obs-text">{{ obs.text }}</div>
|
||||||
|
<div class="vibe-obs-meta">
|
||||||
|
<template v-if="obs.count">×{{ obs.count }} · </template>
|
||||||
|
{{ obs.label }}
|
||||||
|
<template v-if="obs.time"> · {{ obs.time }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vibe-section" v-if="meter">
|
||||||
|
<div class="vibe-meter">
|
||||||
|
<div class="vibe-meter-track">
|
||||||
|
<div class="vibe-meter-fill" :style="{ width: meter.value * 100 + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="vibe-meter-row">
|
||||||
|
<span class="vibe-meter-label">{{ meter.label }}</span>
|
||||||
|
<span class="vibe-meter-caption">{{ meter.caption }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vibe-quote" v-if="quote">
|
||||||
|
<div class="vibe-quote-text">{{ quote.text }}</div>
|
||||||
|
<div class="vibe-quote-caption" v-if="quote.caption">— {{ quote.caption }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './card-base.css';
|
||||||
|
|
||||||
|
.vibe-content {
|
||||||
|
flex: 1; padding: 0 36px 32px;
|
||||||
|
display: flex; flex-direction: column; gap: 22px;
|
||||||
|
overflow-y: auto; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.vibe-section { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.vibe-observations { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.vibe-obs {
|
||||||
|
display: flex; align-items: baseline; gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-left: 2px solid var(--tg-mid);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-left-color var(--theme-ease);
|
||||||
|
}
|
||||||
|
.vibe-obs-text {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 18px; line-height: 1.4; color: var(--fg); flex: 1;
|
||||||
|
}
|
||||||
|
.vibe-obs-text::before { content: '\201C'; color: var(--muted-2); }
|
||||||
|
.vibe-obs-text::after { content: '\201D'; color: var(--muted-2); }
|
||||||
|
.vibe-obs-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 12px; color: var(--muted);
|
||||||
|
white-space: nowrap; flex-shrink: 0; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-correction {
|
||||||
|
font-family: var(--font-serif); font-size: 14.5px;
|
||||||
|
line-height: 1.6; color: var(--fg-2);
|
||||||
|
}
|
||||||
|
.vibe-correction :deep(strong) { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.vibe-correction :deep(.vs) { color: var(--muted); font-style: italic; margin: 0 6px; }
|
||||||
|
|
||||||
|
.vibe-meter { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.vibe-meter-track {
|
||||||
|
position: relative; height: 10px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid var(--hairline); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.vibe-meter-fill {
|
||||||
|
position: absolute; top: 0; left: 0; bottom: 0;
|
||||||
|
background: linear-gradient(to right, var(--tc), var(--tc-2));
|
||||||
|
box-shadow: 0 0 12px var(--tg); border-radius: 1px;
|
||||||
|
transition: background var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
.vibe-meter-row {
|
||||||
|
display: flex; align-items: baseline; justify-content: space-between;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.vibe-meter-label {
|
||||||
|
color: var(--muted); font-style: italic;
|
||||||
|
font-family: var(--font-serif); font-size: 14px;
|
||||||
|
}
|
||||||
|
.vibe-meter-caption {
|
||||||
|
color: var(--tc-2); font-weight: 600;
|
||||||
|
transition: color var(--theme-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vibe-quote {
|
||||||
|
margin-top: auto; padding: 18px 0 0;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.vibe-quote-text {
|
||||||
|
font-family: var(--font-serif); font-size: 22px;
|
||||||
|
line-height: 1.4; color: var(--fg); font-weight: 500;
|
||||||
|
letter-spacing: -0.01em; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.vibe-quote-caption {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
summary: String,
|
||||||
|
stats: String,
|
||||||
|
items: Array,
|
||||||
|
verdict: String,
|
||||||
|
idx: { type: Number, default: 4 },
|
||||||
|
total: { type: Number, default: 5 },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="card" data-themed>
|
||||||
|
<div class="eyebrow">
|
||||||
|
<span class="diamond"></span>
|
||||||
|
<span>Workflows</span>
|
||||||
|
<span class="eyebrow-spacer"></span>
|
||||||
|
<span class="slot">{{ String(idx).padStart(2, '0') }} · {{ String(total).padStart(2, '0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ title }}</div>
|
||||||
|
<div class="card-deck-text" v-if="summary">{{ summary }}</div>
|
||||||
|
|
||||||
|
<div class="wf-content">
|
||||||
|
<div class="wf-stats" v-if="stats">{{ stats }}</div>
|
||||||
|
|
||||||
|
<div class="wf-list">
|
||||||
|
<div v-for="(item, i) in items" :key="i" class="wf-item">
|
||||||
|
<div class="wf-item-name">{{ item.name }}</div>
|
||||||
|
<div class="wf-item-outcome">{{ item.outcome }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wf-verdict" v-if="verdict">
|
||||||
|
<div class="wf-verdict-label">Verdict —</div>
|
||||||
|
<div class="wf-verdict-text">{{ verdict }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@import './card-base.css';
|
||||||
|
|
||||||
|
.wf-content {
|
||||||
|
flex: 1; padding: 0 36px 32px;
|
||||||
|
display: flex; flex-direction: column; gap: 18px;
|
||||||
|
overflow-y: auto; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.wf-stats {
|
||||||
|
font-family: var(--font-mono); font-size: 13px; color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums; display: flex; gap: 14px;
|
||||||
|
}
|
||||||
|
.wf-stats :deep(strong) { color: var(--fg); font-weight: 500; }
|
||||||
|
.wf-stats :deep(.sep) { color: var(--muted-3); }
|
||||||
|
|
||||||
|
.wf-list {
|
||||||
|
display: flex; flex-direction: column; gap: 1px;
|
||||||
|
background: var(--hairline); border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.wf-item {
|
||||||
|
padding: 14px 16px; background: rgba(10,11,20,0.4);
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.wf-item-name {
|
||||||
|
font-family: var(--font-mono); font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--fg); letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.wf-item-outcome {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 16px; color: var(--fg-2); line-height: 1.4;
|
||||||
|
}
|
||||||
|
.wf-item-outcome::before { content: '\201C'; color: var(--muted-2); }
|
||||||
|
.wf-item-outcome::after { content: '\201D'; color: var(--muted-2); }
|
||||||
|
|
||||||
|
.wf-verdict {
|
||||||
|
margin-top: auto; padding: 16px 18px;
|
||||||
|
border: 1px solid var(--hairline-strong); border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.wf-verdict::before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0; width: 2px;
|
||||||
|
background: var(--tc); box-shadow: 0 0 8px var(--tg);
|
||||||
|
transition: background var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
.wf-verdict-label {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.wf-verdict-text {
|
||||||
|
font-family: var(--font-serif); font-size: 22px; font-weight: 500;
|
||||||
|
color: var(--fg); letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export const PALETTES = {
|
||||||
|
architect: { tc: '#a78bfa', tc2: '#c4b5fd', glow: 'rgba(167,139,250,0.40)', mid: 'rgba(167,139,250,0.22)', soft: 'rgba(167,139,250,0.10)' },
|
||||||
|
debugger: { tc: '#fbbf24', tc2: '#fde68a', glow: 'rgba(251,191,36,0.40)', mid: 'rgba(251,191,36,0.22)', soft: 'rgba(251,191,36,0.10)' },
|
||||||
|
shipper: { tc: '#f472b6', tc2: '#fda4af', glow: 'rgba(244,114,182,0.40)', mid: 'rgba(244,114,182,0.22)', soft: 'rgba(244,114,182,0.10)' },
|
||||||
|
curator: { tc: '#67e8f9', tc2: '#a5f3fc', glow: 'rgba(103,232,249,0.40)', mid: 'rgba(103,232,249,0.22)', soft: 'rgba(103,232,249,0.10)' },
|
||||||
|
director: { tc: '#fcd34d', tc2: '#fde68a', glow: 'rgba(252,211,77,0.40)', mid: 'rgba(252,211,77,0.22)', soft: 'rgba(252,211,77,0.10)' },
|
||||||
|
cartographer: { tc: '#34d399', tc2: '#6ee7b7', glow: 'rgba(52,211,153,0.40)', mid: 'rgba(52,211,153,0.22)', soft: 'rgba(52,211,153,0.10)' },
|
||||||
|
wanderer: { tc: '#64748b', tc2: '#94a3b8', glow: 'rgba(100,116,139,0.45)', mid: 'rgba(100,116,139,0.25)', soft: 'rgba(100,116,139,0.12)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARCHETYPE_NAMES = {
|
||||||
|
architect: 'The Architect',
|
||||||
|
debugger: 'The Debugger',
|
||||||
|
shipper: 'The Shipper',
|
||||||
|
curator: 'The Curator',
|
||||||
|
director: 'The Director',
|
||||||
|
cartographer: 'The Cartographer',
|
||||||
|
wanderer: 'The Wanderer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARCH_KEYS = ['architect', 'debugger', 'shipper', 'curator', 'director', 'cartographer', 'wanderer'];
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.card {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(165deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.015) 100%);
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
box-shadow:
|
||||||
|
0 30px 80px rgba(0,0,0,0.5),
|
||||||
|
0 12px 32px rgba(0,0,0,0.3),
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card[data-themed]::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; pointer-events: none; z-index: 0;
|
||||||
|
width: 60%; height: 50%; bottom: 0; right: 0;
|
||||||
|
background: radial-gradient(ellipse at 100% 100%, var(--tg-mid) 0%, transparent 70%);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: background var(--theme-ease);
|
||||||
|
}
|
||||||
|
.card[data-themed]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; pointer-events: none; z-index: 0;
|
||||||
|
left: 0; right: 0; bottom: 0; height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent 0%, var(--tg) 50%, transparent 100%);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: background var(--theme-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 22px 28px 0;
|
||||||
|
font-family: var(--font-mono); font-size: 12px;
|
||||||
|
color: var(--muted); letter-spacing: 0.01em;
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.eyebrow .diamond {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--tc); transform: rotate(45deg);
|
||||||
|
box-shadow: 0 0 8px var(--tg); flex-shrink: 0;
|
||||||
|
transition: background var(--theme-ease), box-shadow var(--theme-ease);
|
||||||
|
}
|
||||||
|
.eyebrow-spacer { flex: 1; }
|
||||||
|
.eyebrow .slot {
|
||||||
|
color: var(--muted-2); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
padding: 18px 36px 6px;
|
||||||
|
font-family: var(--font-serif); font-size: 30px;
|
||||||
|
letter-spacing: -0.015em; font-weight: 500;
|
||||||
|
color: var(--fg); line-height: 1.2;
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.card-deck-text {
|
||||||
|
padding: 0 36px 22px;
|
||||||
|
font-size: 15px; color: var(--fg-3);
|
||||||
|
line-height: 1.55; font-style: italic;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-family: var(--font-serif); font-style: italic;
|
||||||
|
font-size: 13px; color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export const MINI_SEALS = {
|
||||||
|
architect: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#a78bfa" stroke-width="4" stroke-opacity="0.7"/><polygon points="55,32 50,42 60,42" fill="#c4b5fd"/><polygon points="50,42 60,42 58,72 52,72" fill="#a78bfa"/></svg>`,
|
||||||
|
debugger: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fbbf24" stroke-width="4" stroke-opacity="0.7"/><path d="M 55 34 A 21 21 0 1 1 34 55 A 16 16 0 1 0 55 39 A 11 11 0 1 1 44 55" stroke="#fde68a" stroke-width="3.5" fill="none" stroke-linecap="round"/></svg>`,
|
||||||
|
shipper: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#f472b6" stroke-width="4" stroke-opacity="0.7"/><rect x="36" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.45"/><rect x="50" y="48" width="13" height="13" rx="1.5" fill="#f472b6" opacity="0.85"/><rect x="64" y="48" width="13" height="13" rx="1.5" fill="#fda4af"/></svg>`,
|
||||||
|
curator: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#67e8f9" stroke-width="4" stroke-opacity="0.7"/><rect x="34" y="46" width="42" height="5" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="38" y="55" width="34" height="5" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="34" y="64" width="42" height="5" rx="1" fill="#22d3ee" opacity="0.55"/></svg>`,
|
||||||
|
director: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#fcd34d" stroke-width="4" stroke-opacity="0.7"/><g stroke="#fde68a" stroke-width="3" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="36"/><line x1="55" y1="55" x2="72" y2="44"/><line x1="55" y1="55" x2="72" y2="66"/><line x1="55" y1="55" x2="55" y2="74"/><line x1="55" y1="55" x2="38" y2="66"/><line x1="55" y1="55" x2="38" y2="44"/></g><circle cx="55" cy="55" r="4" fill="#fcd34d"/></svg>`,
|
||||||
|
cartographer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#34d399" stroke-width="4" stroke-opacity="0.7"/><g stroke="#34d399" stroke-width="1.5" stroke-opacity="0.4" stroke-dasharray="3 3"><line x1="34" y1="55" x2="76" y2="55"/><line x1="55" y1="34" x2="55" y2="76"/></g><polygon points="55,38 51,55 55,53 59,55" fill="#6ee7b7"/><polygon points="55,38 55,53 59,55" fill="#34d399"/></svg>`,
|
||||||
|
wanderer: `<svg viewBox="0 0 110 110" fill="none"><circle cx="55" cy="55" r="36" stroke="#64748b" stroke-width="4" stroke-opacity="0.85"/><path d="M 38 40 C 44 50, 50 44, 54 50 C 60 60, 50 66, 56 72 C 62 78, 70 66, 76 70" stroke="#94a3b8" stroke-width="2.6" fill="none" stroke-linecap="round" opacity="0.95"/><circle cx="38" cy="40" r="3" fill="#94a3b8"/><circle cx="76" cy="70" r="3" fill="#94a3b8"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CORNER_SEALS = {
|
||||||
|
architect: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-arc" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#a78bfa" stop-opacity="0.5"/><stop offset="100%" stop-color="#a78bfa" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-arc)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#a78bfa" stroke-width="1.4" stroke-opacity="0.85"/><polygon points="55,28 50,38 60,38" fill="#c4b5fd"/><polygon points="50,38 60,38 58,76 52,76" fill="#a78bfa"/><polygon points="55,38 60,38 58,76 55,76" fill="#7c3aed" opacity="0.75"/><rect x="48" y="76" width="14" height="2" rx="0.4" fill="#1e293b"/></svg>`,
|
||||||
|
debugger: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dbg" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fbbf24" stop-opacity="0.5"/><stop offset="100%" stop-color="#fbbf24" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dbg)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fbbf24" stroke-width="1.4" stroke-opacity="0.85"/><path d="M 55 27 A 28 28 0 1 1 27 55 A 22 22 0 1 0 55 33 A 16 16 0 1 1 39 55 A 11 11 0 1 0 55 44 A 6 6 0 1 1 49 55 L 55 55" stroke="#fde68a" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="55" cy="55" r="2.5" fill="#fde68a"/></svg>`,
|
||||||
|
shipper: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-shp" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#f472b6" stop-opacity="0.5"/><stop offset="100%" stop-color="#f472b6" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-shp)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#f472b6" stroke-width="1.4" stroke-opacity="0.85"/><rect x="32" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.35"/><rect x="48" y="48" width="14" height="14" rx="1.5" fill="#f472b6" opacity="0.65"/><rect x="64" y="48" width="14" height="14" rx="1.5" fill="#fda4af"/><path d="M 32 72 L 78 72 M 73 68 L 78 72 L 73 76" stroke="#fda4af" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`,
|
||||||
|
curator: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cur" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#67e8f9" stop-opacity="0.45"/><stop offset="100%" stop-color="#67e8f9" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cur)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#67e8f9" stroke-width="1.4" stroke-opacity="0.85"/><rect x="30" y="42" width="50" height="6" rx="1" fill="#a5f3fc" opacity="0.85"/><rect x="34" y="52" width="42" height="6" rx="1" fill="#67e8f9" opacity="0.7"/><rect x="30" y="62" width="50" height="6" rx="1" fill="#22d3ee" opacity="0.55"/><rect x="38" y="72" width="34" height="4" rx="1" fill="#0891b2" opacity="0.5"/></svg>`,
|
||||||
|
director: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-dir" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#fcd34d" stop-opacity="0.45"/><stop offset="100%" stop-color="#fcd34d" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-dir)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#fcd34d" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#fde68a" stroke-width="1.1" stroke-linecap="round" opacity="0.85"><line x1="55" y1="55" x2="55" y2="32"/><line x1="55" y1="55" x2="74" y2="42"/><line x1="55" y1="55" x2="74" y2="68"/><line x1="55" y1="55" x2="55" y2="78"/><line x1="55" y1="55" x2="36" y2="68"/><line x1="55" y1="55" x2="36" y2="42"/></g><g fill="#fde68a"><circle cx="55" cy="32" r="2.5"/><circle cx="74" cy="42" r="2.5"/><circle cx="74" cy="68" r="2.5"/><circle cx="55" cy="78" r="2.5"/><circle cx="36" cy="68" r="2.5"/><circle cx="36" cy="42" r="2.5"/></g><circle cx="55" cy="55" r="3.5" fill="#fcd34d"/></svg>`,
|
||||||
|
cartographer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-cart" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#34d399" stop-opacity="0.5"/><stop offset="100%" stop-color="#34d399" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-cart)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#34d399" stroke-width="1.4" stroke-opacity="0.85"/><g stroke="#34d399" stroke-width="0.5" stroke-opacity="0.4" stroke-dasharray="2 2"><line x1="32" y1="44" x2="78" y2="44"/><line x1="32" y1="55" x2="78" y2="55"/><line x1="32" y1="66" x2="78" y2="66"/><line x1="44" y1="32" x2="44" y2="78"/><line x1="55" y1="32" x2="55" y2="78"/><line x1="66" y1="32" x2="66" y2="78"/></g><polygon points="55,38 52.5,55 55,53 57.5,55" fill="#6ee7b7"/><polygon points="55,38 55,53 57.5,55" fill="#34d399"/><polygon points="55,72 52.5,55 55,57 57.5,55" fill="#34d399" opacity="0.6"/><polygon points="72,55 55,52.5 57,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><polygon points="38,55 55,52.5 53,55 55,57.5" fill="#6ee7b7" opacity="0.7"/><circle cx="55" cy="55" r="2" fill="#0a0b14"/><circle cx="55" cy="55" r="2.4" stroke="#6ee7b7" stroke-width="0.6" fill="none"/></svg>`,
|
||||||
|
wanderer: `<svg viewBox="0 0 110 110" fill="none"><defs><radialGradient id="cs-wand" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#64748b" stop-opacity="0.5"/><stop offset="100%" stop-color="#64748b" stop-opacity="0"/></radialGradient></defs><circle cx="55" cy="55" r="50" fill="url(#cs-wand)"/><circle cx="55" cy="55" r="42" stroke="rgba(255,255,255,0.20)" stroke-width="1"/><circle cx="55" cy="55" r="38" stroke="#64748b" stroke-width="1.5" stroke-opacity="0.9"/><path d="M 36 38 C 42 50, 48 42, 54 50 C 60 60, 50 65, 56 72 C 62 78, 70 64, 76 70" stroke="#94a3b8" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" opacity="0.95"/><circle cx="36" cy="38" r="2.4" fill="#94a3b8"/><circle cx="54" cy="50" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="56" cy="72" r="1.7" fill="#94a3b8" opacity="0.9"/><circle cx="76" cy="70" r="2.4" fill="#94a3b8"/></svg>`,
|
||||||
|
};
|
||||||
@@ -133,6 +133,7 @@ export async function loadSessionDetail(sessionId) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Assemble messages with tool_calls inline
|
// Assemble messages with tool_calls inline
|
||||||
|
const META_RE = /^\s*<(task-notification|command-name|local-command|system-reminder)/;
|
||||||
const rawAssembled = (messages || []).map(msg => {
|
const rawAssembled = (messages || []).map(msg => {
|
||||||
const assembled = {
|
const assembled = {
|
||||||
uuid: msg.uuid,
|
uuid: msg.uuid,
|
||||||
@@ -140,7 +141,7 @@ export async function loadSessionDetail(sessionId) {
|
|||||||
timestamp: msg.timestamp,
|
timestamp: msg.timestamp,
|
||||||
text: msg.text,
|
text: msg.text,
|
||||||
content_type: msg.content_type || null,
|
content_type: msg.content_type || null,
|
||||||
is_meta: msg.is_meta || 0
|
is_meta: msg.is_meta || (msg.text && META_RE.test(msg.text) ? 1 : 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
const calls = callsByMessageUuid[msg.uuid];
|
const calls = callsByMessageUuid[msg.uuid];
|
||||||
@@ -180,15 +181,25 @@ export async function loadSessionDetail(sessionId) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
|
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results and skill meta)
|
||||||
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||||
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||||
if (msg._thinking) merged._thinking = msg._thinking;
|
if (msg._thinking) merged._thinking = msg._thinking;
|
||||||
|
|
||||||
|
// If this is a Skill-only message, don't merge with subsequent tool_use — keep it standalone
|
||||||
|
const isSkillOnly = merged.tool_calls.length === 1 && merged.tool_calls[0].name === 'Skill';
|
||||||
|
|
||||||
let j = i + 1;
|
let j = i + 1;
|
||||||
while (j < rawAssembled.length) {
|
while (j < rawAssembled.length) {
|
||||||
const next = rawAssembled[j];
|
const next = rawAssembled[j];
|
||||||
if (next.content_type === 'tool_result') { j++; continue; }
|
if (next.content_type === 'tool_result') { j++; continue; }
|
||||||
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
// Absorb skill.md meta message into the skill tool call
|
||||||
|
if (next.is_meta && next.text && next.text.includes('Base directory for this skill')) {
|
||||||
|
merged._skillMd = next.text;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isSkillOnly && next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||||
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||||
if (next.text && !merged.text) merged.text = next.text;
|
if (next.text && !merged.text) merged.text = next.text;
|
||||||
j++;
|
j++;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "obelisk.recap.v1",
|
||||||
|
"kind": "weekly",
|
||||||
|
"generated_at": "2026-06-14T03:00:00+08:00",
|
||||||
|
|
||||||
|
"period": {
|
||||||
|
"label": "Week 24",
|
||||||
|
"start": "2026-06-08",
|
||||||
|
"end": "2026-06-14",
|
||||||
|
"timezone": "Asia/Shanghai"
|
||||||
|
},
|
||||||
|
|
||||||
|
"source": {
|
||||||
|
"project": "-Users-tomiya-Code-quiet-zero",
|
||||||
|
"session_ids": ["defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "9d259960-8eae-4bae-947c-081420bb5626"],
|
||||||
|
"memory_ids": ["mem-1781021027286-51v0qh"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"metrics": {
|
||||||
|
"sessions": 12,
|
||||||
|
"messages": 847,
|
||||||
|
"tokens": 2400000,
|
||||||
|
"active_days": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
|
||||||
|
"streak_days": 19,
|
||||||
|
"workflows": 3,
|
||||||
|
"workflow_agents": 42,
|
||||||
|
"corrections": 12
|
||||||
|
},
|
||||||
|
|
||||||
|
"persona": {
|
||||||
|
"archetype": "architect",
|
||||||
|
"title": "The Architect",
|
||||||
|
"subtitle": "从零设计了一个完整的 memory 系统。",
|
||||||
|
"tone": "affectionate_teasing"
|
||||||
|
},
|
||||||
|
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"badge": "Week 24",
|
||||||
|
"title": "The Architect",
|
||||||
|
"subtitle": "从零设计了一个完整的 memory 系统。",
|
||||||
|
"activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
|
||||||
|
"footer": "12 sessions · 2.4M tokens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "thinking_path",
|
||||||
|
"title": "Five questions, five turns.",
|
||||||
|
"items": [
|
||||||
|
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki?", "outcome": "raw SQLite, no wiki" },
|
||||||
|
{ "day": "Tue", "prompt": "buildWhere 是什么", "outcome": "unified filter opts, not DSL" },
|
||||||
|
{ "day": "Wed", "prompt": "failures() 90% 误报", "outcome": "is_error in JSONL" },
|
||||||
|
{ "day": "Thu", "prompt": "memory 层需要清理机制吗", "outcome": "soft-delete, human-only" },
|
||||||
|
{ "day": "Fri", "prompt": "热力图不选中默认显示本月", "outcome": "GitHub-style activity timeline" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vibe",
|
||||||
|
"title": "A short character study.",
|
||||||
|
"observations": [
|
||||||
|
{ "label": "catchphrase", "text": "这太丑了", "count": 4 },
|
||||||
|
{ "label": "highest praise", "text": "可以" },
|
||||||
|
{ "label": "late night", "text": "你在干什么", "time": "02:47 AM" }
|
||||||
|
],
|
||||||
|
"meter": {
|
||||||
|
"label": "patience",
|
||||||
|
"value": 0.78,
|
||||||
|
"caption": "saint"
|
||||||
|
},
|
||||||
|
"quote": {
|
||||||
|
"text": "若无必要,勿增实体。",
|
||||||
|
"caption": "your most philosophical moment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "workflow",
|
||||||
|
"title": "Three workflows. Forty-two agents.",
|
||||||
|
"summary": "你召唤了机器军团。结果各有不同。",
|
||||||
|
"stats": "3 workflows · 42 agents",
|
||||||
|
"items": [
|
||||||
|
{ "name": "hono-plugin-review", "outcome": "完美" },
|
||||||
|
{ "name": "vue-migration", "outcome": "你这页面完全和之前的不一样…" },
|
||||||
|
{ "name": "split-render-js", "outcome": "可以" }
|
||||||
|
],
|
||||||
|
"verdict": "Mostly tolerated."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "closing",
|
||||||
|
"headline": "19 days",
|
||||||
|
"stats": ["847 messages exchanged", "12 corrections · 47 approvals"],
|
||||||
|
"most_said_phrase": "好的开始做吧",
|
||||||
|
"signoff": "See you next week."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"evidence": [
|
||||||
|
{ "id": "ev-1", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-1", "summary": "User said '若无必要,勿增实体' when discussing query builder" },
|
||||||
|
{ "id": "ev-2", "session_id": "defd4ccd-b2d7-4c07-a32b-0a7b74e8aace", "message_uuid": "some-uuid-2", "summary": "User said '这太丑了' about panel design" },
|
||||||
|
{ "id": "ev-3", "summary": "12 corrections vs 47 approvals in session messages" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ const SubagentDetail = () => import('./views/SubagentDetail.vue');
|
|||||||
const MemoryList = () => import('./views/MemoryList.vue');
|
const MemoryList = () => import('./views/MemoryList.vue');
|
||||||
const MemoryDetail = () => import('./views/MemoryDetail.vue');
|
const MemoryDetail = () => import('./views/MemoryDetail.vue');
|
||||||
const Activity = () => import('./views/Activity.vue');
|
const Activity = () => import('./views/Activity.vue');
|
||||||
|
const Recap = () => import('./views/RecapList.vue');
|
||||||
|
const RecapDetail = () => import('./views/RecapDetail.vue');
|
||||||
|
const RecapExport = () => import('./views/RecapExport.vue');
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -46,6 +49,22 @@ const routes = [
|
|||||||
name: 'Activity',
|
name: 'Activity',
|
||||||
component: Activity
|
component: Activity
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/recap',
|
||||||
|
name: 'Recap',
|
||||||
|
component: Recap
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recap/:id',
|
||||||
|
name: 'RecapDetail',
|
||||||
|
component: RecapDetail,
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recap-export',
|
||||||
|
name: 'RecapExport',
|
||||||
|
component: RecapExport
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/memory'
|
redirect: '/memory'
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function goToSession() {
|
|||||||
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading…</div>
|
<div v-if="loading" style="color:var(--muted);padding:20px;text-align:center;">Loading…</div>
|
||||||
<div v-else-if="markdown == null" style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>
|
<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>
|
<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>
|
||||||
|
|
||||||
<div v-if="memory.anchors && memory.anchors.length" class="detail-section-divider" id="anchors-section">
|
<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>
|
<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 { useRouter } from 'vue-router';
|
||||||
import { state, FOLDER_SVG } from '../store.js';
|
import { state, FOLDER_SVG } from '../store.js';
|
||||||
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.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 messages = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const progressPct = ref(0);
|
const progressPct = ref(0);
|
||||||
const showBackToTop = ref(false);
|
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
const wrapRef = ref(null);
|
const wrapRef = ref(null);
|
||||||
const detailRef = ref(null);
|
const detailRef = ref(null);
|
||||||
|
|
||||||
// --- Load session on mount or when id changes ---
|
// --- 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 () => {
|
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();
|
await loadMessages();
|
||||||
});
|
});
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
|
window.addEventListener('keydown', handleZoom);
|
||||||
if (messages.value.length === 0 && props.id) {
|
if (messages.value.length === 0 && props.id) {
|
||||||
await loadMessages();
|
await loadMessages();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleZoom);
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => props.id, async (newId, oldId) => {
|
watch(() => props.id, async (newId, oldId) => {
|
||||||
if (newId && newId !== oldId) {
|
if (newId && newId !== oldId) {
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -74,23 +118,37 @@ async function loadMessages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Scroll / progress tracking ---
|
// --- Scroll / progress tracking ---
|
||||||
|
const currentMsgIdx = ref(0);
|
||||||
|
const totalMsgs = ref(0);
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
if (!wrapRef.value || !detailRef.value) return;
|
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;
|
if (!msgs.length) return;
|
||||||
|
totalMsgs.value = msgs.length;
|
||||||
const wrapTop = wrapRef.value.getBoundingClientRect().top;
|
const wrapTop = wrapRef.value.getBoundingClientRect().top;
|
||||||
let topMsgIdx = 0;
|
let topMsgIdx = 0;
|
||||||
for (let i = 0; i < msgs.length; i++) {
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||||
else break;
|
else break;
|
||||||
}
|
}
|
||||||
|
currentMsgIdx.value = topMsgIdx;
|
||||||
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||||
progressPct.value = pct;
|
progressPct.value = pct;
|
||||||
showBackToTop.value = wrapRef.value.scrollTop > 300;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToTop() {
|
function navTo(target) {
|
||||||
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
|
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 ---
|
// --- Toggle helpers ---
|
||||||
@@ -147,13 +205,303 @@ function getArgPreview(tc) {
|
|||||||
if (j.file_path) return j.file_path;
|
if (j.file_path) return j.file_path;
|
||||||
if (j.command) return j.command;
|
if (j.command) return j.command;
|
||||||
if (j.path) return j.path;
|
if (j.path) return j.path;
|
||||||
|
if (j.query) return j.query;
|
||||||
if (j.description) return j.description;
|
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 {
|
} 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) {
|
function getToolCallParsedInput(tc) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(tc.input_json || '{}');
|
return JSON.parse(tc.input_json || '{}');
|
||||||
@@ -164,7 +512,7 @@ function getToolCallParsedInput(tc) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="detail" ref="detailRef">
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div class="session-progress">
|
<div class="session-progress">
|
||||||
@@ -275,17 +623,26 @@ function getToolCallParsedInput(tc) {
|
|||||||
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
<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>
|
<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-name">{{ tc.name }}</span>
|
||||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="toolcall-body">
|
<div class="toolcall-body">
|
||||||
<div class="tc-section">Input</div>
|
<div class="toolcall-body-strip">
|
||||||
<pre>{{ tc.input_json || '' }}</pre>
|
<span class="strip-label">{{ tc.name }}</span>
|
||||||
<template v-if="tc.result">
|
<span class="spacer"></span>
|
||||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
||||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
</div>
|
||||||
</template>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -295,6 +652,29 @@ function getToolCallParsedInput(tc) {
|
|||||||
</template>
|
</template>
|
||||||
</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 -->
|
<!-- Standalone thinking message -->
|
||||||
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||||
<div class="msg assistant" :data-uuid="msg.uuid">
|
<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">
|
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
|
||||||
<template v-for="tc in msg.tool_calls" :key="tc.id">
|
<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) -->
|
<!-- 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">
|
<div class="msg-tool agent-call">
|
||||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
<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>
|
<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 || '')"
|
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
|
||||||
>View conversation →</button>
|
>View conversation →</button>
|
||||||
</button>
|
</button>
|
||||||
<div class="toolcall-body">
|
<div class="toolcall-body" style="padding:10px 12px;">
|
||||||
<template v-if="getToolCallParsedInput(tc).prompt">
|
<template v-if="getToolCallParsedInput(tc).prompt">
|
||||||
<div class="tc-section">Prompt</div>
|
<div class="tc-section">Prompt</div>
|
||||||
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</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>
|
>{{ tc.workflow.status }}</span>
|
||||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="toolcall-body">
|
<div class="toolcall-body" style="padding:10px 12px;">
|
||||||
<template v-if="tc.workflow?.agents?.length">
|
<template v-if="tc.workflow?.agents?.length">
|
||||||
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||||
<div class="workflow-agent-list">
|
<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 }">
|
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||||
<button class="toolcall-toggle" @click="toggleToolCall">
|
<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>
|
<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-name">{{ tc.name }}</span>
|
||||||
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||||
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="toolcall-body">
|
<div class="toolcall-body">
|
||||||
<div class="tc-section">Input</div>
|
<div class="toolcall-body-strip">
|
||||||
<pre>{{ tc.input_json || '' }}</pre>
|
<span class="strip-label">{{ tc.name }}</span>
|
||||||
<template v-if="tc.result">
|
<span class="spacer"></span>
|
||||||
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
<button class="raw-toggle" @click.stop="toggleRaw">{ } Raw</button>
|
||||||
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
</div>
|
||||||
</template>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -460,16 +857,30 @@ function getToolCallParsedInput(tc) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Back to top button -->
|
<!-- Pagination nav -->
|
||||||
<button
|
<div class="msg-nav" v-if="totalMsgs > 0">
|
||||||
class="back-to-top"
|
<button class="msg-nav-btn" @click="navTo('first')" :disabled="currentMsgIdx === 0" title="First">
|
||||||
:class="{ show: showBackToTop }"
|
<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>
|
||||||
@click="scrollToTop"
|
</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="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="showFontHint" class="font-toast">
|
||||||
|
⌘ +/- to adjust font size
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -478,5 +889,26 @@ function getToolCallParsedInput(tc) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
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>
|
</style>
|
||||||
|
|||||||
+341
-23
@@ -320,7 +320,14 @@
|
|||||||
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||||
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||||
|
|
||||||
.markdown-msg { font-size: var(--text-base); line-height: 1.6; color: var(--fg); word-wrap: break-word; }
|
.markdown-msg {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--fg);
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-family: 'Helvetica Neue', 'Inter', -apple-system, system-ui, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
}
|
||||||
.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 {
|
.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 {
|
||||||
font-weight: 600; letter-spacing: -0.01em;
|
font-weight: 600; letter-spacing: -0.01em;
|
||||||
margin: 1em 0 0.4em; line-height: 1.3;
|
margin: 1em 0 0.4em; line-height: 1.3;
|
||||||
@@ -513,6 +520,12 @@
|
|||||||
transition: transform 0.15s; flex-shrink: 0;
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||||
|
.toolcall-toggle .tool-icon {
|
||||||
|
width: 14px; height: 14px; color: var(--accent-2); flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
}
|
||||||
|
.toolcall-toggle .tool-icon svg { width: 14px; height: 14px; }
|
||||||
|
.msg-tool.is-error .toolcall-toggle .tool-icon { color: var(--danger); }
|
||||||
.toolcall-toggle .tool-name {
|
.toolcall-toggle .tool-name {
|
||||||
font-family: var(--font-mono); font-size: 11px;
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
color: var(--accent-2); font-weight: 600; flex-shrink: 0;
|
color: var(--accent-2); font-weight: 600; flex-shrink: 0;
|
||||||
@@ -530,23 +543,313 @@
|
|||||||
letter-spacing: 0.04em; font-weight: 500;
|
letter-spacing: 0.04em; font-weight: 500;
|
||||||
}
|
}
|
||||||
.toolcall-body {
|
.toolcall-body {
|
||||||
display: none; border-top: 1px solid var(--hairline);
|
display: none;
|
||||||
background: rgba(0,0,0,0.35);
|
border-top: 1px solid var(--hairline);
|
||||||
padding: 10px 12px; max-height: 300px; overflow: auto;
|
background: rgba(0,0,0,0.32);
|
||||||
}
|
}
|
||||||
.msg-tool.open .toolcall-body { display: block; }
|
.msg-tool.open .toolcall-body { display: block; }
|
||||||
|
|
||||||
|
.toolcall-body-strip {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.toolcall-body-strip .strip-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.toolcall-body-strip .spacer { flex: 1; }
|
||||||
|
.raw-toggle {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 2px 7px; border-radius: 3px;
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
|
||||||
|
border: 1px solid var(--hairline); cursor: pointer; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.raw-toggle:hover { color: var(--fg-2); border-color: var(--hairline-strong); background: var(--surface-strong); }
|
||||||
|
.raw-toggle.active { color: var(--accent-2); border-color: var(--accent-soft); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
.toolcall-pretty { padding: 10px 12px; }
|
||||||
|
.toolcall-pretty.hidden { display: none; }
|
||||||
|
|
||||||
.toolcall-body .tc-section {
|
.toolcall-body .tc-section {
|
||||||
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
|
||||||
letter-spacing: 0.04em; text-transform: uppercase;
|
letter-spacing: 0.05em; text-transform: uppercase;
|
||||||
margin: 4px 0 4px; font-weight: 500;
|
margin: 0 0 5px; font-weight: 500;
|
||||||
}
|
}
|
||||||
.toolcall-body .tc-section:first-child { margin-top: 0; }
|
|
||||||
.toolcall-body pre {
|
.toolcall-raw {
|
||||||
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5;
|
display: none; padding: 12px 14px; max-height: 400px; overflow: auto;
|
||||||
|
}
|
||||||
|
.toolcall-raw.show { display: block; }
|
||||||
|
.toolcall-raw .tc-section {
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
|
||||||
|
letter-spacing: 0.05em; text-transform: uppercase;
|
||||||
|
margin: 0 0 5px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.toolcall-raw .tc-section + pre { margin-bottom: 12px; }
|
||||||
|
.toolcall-raw pre {
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
|
||||||
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* File reference chip */
|
||||||
|
.file-ref {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 8px; border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid var(--hairline);
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; color: var(--fg);
|
||||||
|
}
|
||||||
|
.file-ref .file-line { color: var(--muted); margin-left: 2px; }
|
||||||
|
|
||||||
|
/* File content viewer */
|
||||||
|
.file-content {
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.4); overflow: hidden;
|
||||||
|
}
|
||||||
|
.file-content-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 10px; background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.file-content-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
|
||||||
|
.file-content-head .meta { margin-left: auto; }
|
||||||
|
.file-content-body {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
|
||||||
|
max-height: 320px; overflow: auto;
|
||||||
|
}
|
||||||
|
.file-content-body.collapsed { max-height: 180px; }
|
||||||
|
.file-content-body .gutter {
|
||||||
|
padding: 6px 10px 6px 12px; color: var(--muted-2); user-select: none;
|
||||||
|
text-align: right; background: rgba(255,255,255,0.015);
|
||||||
|
border-right: 1px solid var(--hairline); white-space: pre;
|
||||||
|
}
|
||||||
|
.file-content-body .code { padding: 6px 12px; color: var(--fg-2); white-space: pre; overflow-x: auto; }
|
||||||
|
.file-content-expand {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
padding: 6px; border-top: 1px solid var(--hairline);
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
width: 100%; cursor: pointer; transition: all 0.1s; border: none;
|
||||||
|
}
|
||||||
|
.file-content-expand:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||||
|
|
||||||
|
/* Diff view */
|
||||||
|
.diff-view {
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.4); overflow: hidden;
|
||||||
|
}
|
||||||
|
.diff-view-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 10px; background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.diff-view-head .label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
|
||||||
|
.diff-view-head .stats { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
.diff-view-head .stat-add { color: rgba(165,180,252,0.85); }
|
||||||
|
.diff-view-head .stat-del { color: rgba(249,168,212,0.7); }
|
||||||
|
.diff-body {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
|
||||||
|
max-height: 380px; overflow: auto;
|
||||||
|
}
|
||||||
|
.diff-body .diff-gutter {
|
||||||
|
padding: 0 10px 0 12px; color: var(--muted-2); user-select: none;
|
||||||
|
text-align: right; background: rgba(255,255,255,0.015);
|
||||||
|
border-right: 1px solid var(--hairline); white-space: pre;
|
||||||
|
}
|
||||||
|
.diff-body .diff-line { padding: 0 12px; white-space: pre; }
|
||||||
|
.diff-body .diff-line.add { background: rgba(99,102,241,0.06); color: rgba(165,180,252,0.85); }
|
||||||
|
.diff-body .diff-line.del { background: rgba(236,72,153,0.06); color: rgba(249,168,212,0.6); text-decoration: line-through; text-decoration-color: rgba(249,168,212,0.25); }
|
||||||
|
.diff-body .diff-line.context { color: var(--fg-2); }
|
||||||
|
.diff-body .diff-gutter.add { background: rgba(99,102,241,0.04); color: rgba(99,102,241,0.55); }
|
||||||
|
.diff-body .diff-gutter.del { background: rgba(236,72,153,0.04); color: rgba(236,72,153,0.45); }
|
||||||
|
|
||||||
|
/* Terminal view */
|
||||||
|
.terminal-view {
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: #07090f; overflow: hidden;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.terminal-prompt-line {
|
||||||
|
display: flex; gap: 8px; padding: 8px 12px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.terminal-prompt-line .prompt-marker { color: #4ade80; font-weight: 600; user-select: none; flex-shrink: 0; }
|
||||||
|
.terminal-prompt-line .prompt-cmd { color: var(--fg); white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.terminal-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.terminal-output {
|
||||||
|
padding: 8px 12px; color: rgba(255,255,255,0.68);
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
max-height: 300px; overflow: auto;
|
||||||
|
border-left: 2px solid rgba(255,255,255,0.06);
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.terminal-output.is-error {
|
||||||
|
color: #fca5a5;
|
||||||
|
border-left-color: rgba(248,113,113,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field grid (generic fallback) */
|
||||||
|
.field-grid {
|
||||||
|
display: grid; grid-template-columns: max-content 1fr;
|
||||||
|
gap: 4px 14px; font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.field-grid .field-key { color: var(--muted); font-weight: 500; padding-top: 1px; }
|
||||||
|
.field-grid .field-val { color: var(--fg-2); word-break: break-word; min-width: 0; }
|
||||||
|
.field-grid .field-val .literal-string { color: var(--accent-2); }
|
||||||
|
.field-grid .field-val .literal-num { color: #fcd34d; }
|
||||||
|
.field-grid .field-val .literal-bool { color: #4ade80; }
|
||||||
|
.field-grid .field-val .literal-null { color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
/* Long string expand */
|
||||||
|
.lit-string-long { display: inline; color: var(--accent-2); cursor: pointer; }
|
||||||
|
.lit-string-long .long-rest { display: none; }
|
||||||
|
.lit-string-long.open .long-rest { display: inline; }
|
||||||
|
.lit-string-long.open .more-btn { display: none; }
|
||||||
|
.lit-string-long .more-btn {
|
||||||
|
display: inline-block; margin-left: 6px;
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
|
||||||
|
padding: 0 5px; border: 1px solid var(--hairline-strong);
|
||||||
|
border-radius: 3px; vertical-align: middle; cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.lit-string-long .more-btn:hover { color: var(--fg-2); border-color: var(--hairline); background: var(--surface-strong); }
|
||||||
|
|
||||||
|
/* Body section label */
|
||||||
|
.body-label {
|
||||||
|
font-family: var(--font-mono); font-size: 9.5px;
|
||||||
|
color: var(--muted); letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase; font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Result chip */
|
||||||
|
.result-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
margin-top: 10px; padding: 5px 10px; border-radius: 4px;
|
||||||
|
background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.18);
|
||||||
|
font-size: 11.5px; color: var(--fg-2);
|
||||||
|
}
|
||||||
|
.result-chip.error { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.25); }
|
||||||
|
|
||||||
|
/* Tool action label */
|
||||||
|
.tool-action-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted);
|
||||||
|
letter-spacing: 0.05em; text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-table for list-of-objects output */
|
||||||
|
.auto-table-wrap {
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.32); overflow: hidden;
|
||||||
|
}
|
||||||
|
.auto-table-head {
|
||||||
|
padding: 6px 10px; background: rgba(255,255,255,0.02);
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.auto-table-head .h-label { color: var(--fg-2); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 10px; }
|
||||||
|
.auto-table-head .h-meta { margin-left: auto; }
|
||||||
|
.auto-table-scroll { max-height: 360px; overflow: auto; }
|
||||||
|
.auto-table {
|
||||||
|
width: 100%; border-collapse: collapse;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
}
|
||||||
|
.auto-table th, .auto-table td {
|
||||||
|
padding: 6px 10px; text-align: left;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
vertical-align: top; line-height: 1.45;
|
||||||
|
}
|
||||||
|
.auto-table th {
|
||||||
|
background: rgba(255,255,255,0.025);
|
||||||
|
font-weight: 500; font-size: 10px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
position: sticky; top: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.auto-table tr:last-child td { border-bottom: 0; }
|
||||||
|
.auto-table tr:hover td { background: rgba(255,255,255,0.015); }
|
||||||
|
|
||||||
|
/* Skill card (standalone timeline item) */
|
||||||
|
.skill-card {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(6, 182, 212, 0.04);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.15);
|
||||||
|
border-left: 3px solid rgba(6, 182, 212, 0.5);
|
||||||
|
}
|
||||||
|
.skill-card-icon {
|
||||||
|
width: 28px; height: 28px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(6, 182, 212, 0.1);
|
||||||
|
color: #67e8f9;
|
||||||
|
}
|
||||||
|
.skill-card-icon svg { width: 14px; height: 14px; }
|
||||||
|
.skill-card-body { flex: 1; min-width: 0; }
|
||||||
|
.skill-card-header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.skill-card-badge {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
color: #67e8f9;
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
}
|
||||||
|
.skill-card-name {
|
||||||
|
font-size: 14px; font-weight: 500; color: var(--fg);
|
||||||
|
}
|
||||||
|
.skill-card-args {
|
||||||
|
font-size: 12px; color: var(--fg-3); line-height: 1.5;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card-md { margin-top: 8px; }
|
||||||
|
.skill-md-toggle {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
border: none; background: none; padding: 2px 0;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
.skill-md-toggle:hover { color: var(--fg-2); }
|
||||||
|
.skill-md-toggle .chevron {
|
||||||
|
width: 8px; height: 8px; transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.skill-card.skill-md-open .skill-md-toggle .chevron { transform: rotate(90deg); }
|
||||||
|
.skill-md-body {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px; padding: 12px;
|
||||||
|
max-height: 400px; overflow-y: auto;
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.skill-card.skill-md-open .skill-md-body { display: block; }
|
||||||
|
|
||||||
|
/* Skill badge (inside assistant bubble, for mixed messages) */
|
||||||
|
.skill-badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
}
|
||||||
|
.skill-badge .skill-label { color: var(--muted); }
|
||||||
|
.skill-badge .skill-name { color: var(--accent-2); font-weight: 500; }
|
||||||
|
|
||||||
.tc-subagent {
|
.tc-subagent {
|
||||||
margin-top: 8px; padding: 8px 10px;
|
margin-top: 8px; padding: 8px 10px;
|
||||||
border: 1px solid var(--workflow-soft);
|
border: 1px solid var(--workflow-soft);
|
||||||
@@ -860,16 +1163,31 @@
|
|||||||
.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
|
.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
/* Back to top floating button */
|
/* Back to top floating button */
|
||||||
.back-to-top {
|
/* Message pagination nav */
|
||||||
position: sticky; bottom: 20px; float: right;
|
.msg-nav {
|
||||||
margin-right: 8px; margin-top: -40px;
|
position: fixed; bottom: 40px;
|
||||||
width: 32px; height: 32px; border-radius: 50%;
|
left: 50%; transform: translateX(-50%);
|
||||||
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
|
display: flex; align-items: center; gap: 4px;
|
||||||
color: var(--muted); cursor: pointer;
|
padding: 5px 8px;
|
||||||
display: grid; place-items: center;
|
border-radius: 8px;
|
||||||
opacity: 0; transition: opacity 0.15s, background 0.1s;
|
background: rgba(10, 11, 20, 0.85);
|
||||||
pointer-events: none; z-index: 10;
|
border: 1px solid var(--hairline-strong);
|
||||||
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
.back-to-top.show { opacity: 1; pointer-events: auto; }
|
.msg-nav-btn {
|
||||||
.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
width: 28px; height: 28px;
|
||||||
.back-to-top svg { width: 14px; height: 14px; }
|
display: grid; place-items: center;
|
||||||
|
border-radius: 5px; border: none; background: none;
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.msg-nav-btn:hover:not(:disabled) { color: var(--fg); background: var(--surface-strong); }
|
||||||
|
.msg-nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||||
|
.msg-nav-btn svg { width: 13px; height: 13px; }
|
||||||
|
.msg-nav-pos {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--muted); padding: 0 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.msg-nav-pos .msg-nav-current { color: var(--fg-2); font-weight: 500; }
|
||||||
|
|||||||
@@ -79,3 +79,33 @@
|
|||||||
.sort-group.desc .arrow-down { opacity: 1; }
|
.sort-group.desc .arrow-down { opacity: 1; }
|
||||||
.sort-group.asc .arrow-up { opacity: 1; }
|
.sort-group.asc .arrow-up { opacity: 1; }
|
||||||
.sort-group.asc .arrow-down { opacity: 0.25; }
|
.sort-group.asc .arrow-down { opacity: 0.25; }
|
||||||
|
|
||||||
|
.tab-group {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--hairline-strong); border-radius: 5px;
|
||||||
|
overflow: hidden; height: 26px;
|
||||||
|
}
|
||||||
|
.tab-group button {
|
||||||
|
padding: 0 12px; font-size: 12px; color: var(--muted);
|
||||||
|
border: none; background: none; cursor: pointer;
|
||||||
|
border-right: 1px solid var(--hairline-strong);
|
||||||
|
display: inline-flex; align-items: center; transition: all 0.1s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.tab-group button:last-child { border-right: 0; }
|
||||||
|
.tab-group button:hover { background: var(--surface); color: var(--fg-2); }
|
||||||
|
.tab-group button.active { background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
|
||||||
|
.toolbar-action-primary {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
height: 26px; padding: 0 12px;
|
||||||
|
border: 1px solid rgba(167,139,250,0.35); border-radius: 5px;
|
||||||
|
background: var(--accent-soft); color: var(--accent-2);
|
||||||
|
font-size: 12px; font-weight: 500; cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.toolbar-action-primary:hover {
|
||||||
|
background: rgba(167,139,250,0.18); border-color: var(--accent);
|
||||||
|
color: var(--fg); box-shadow: 0 0 12px rgba(167,139,250,0.20);
|
||||||
|
}
|
||||||
|
.toolbar-action-primary .plus { font-size: 14px; line-height: 1; opacity: 0.8; font-weight: 400; }
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# Obelisk Recap Patterns
|
||||||
|
|
||||||
|
Use this when Obelisk Intent Routing sends the `recap` intent. In practice,
|
||||||
|
that means the first word after `/obelisk` is `recap`; everything after it is
|
||||||
|
the recap target.
|
||||||
|
|
||||||
|
Do not self-trigger this reference from broad weekly/monthly summaries, charts,
|
||||||
|
rankings, shareable-card language, or playlist-style metaphors. Those are target
|
||||||
|
details only after the explicit `recap` intent has already selected this file.
|
||||||
|
|
||||||
|
This is an optional app handoff pattern. Obelisk stays agent-first: the agent
|
||||||
|
queries sessions and memories, judges what matters, then fills card content.
|
||||||
|
The app owns rendering, layout, export, and animation.
|
||||||
|
|
||||||
|
## Common Period Targets
|
||||||
|
|
||||||
|
Treat the target after `recap` as normal user language. Common app-generated
|
||||||
|
targets:
|
||||||
|
|
||||||
|
- `/obelisk recap this week` -- the current calendar week in the user's timezone.
|
||||||
|
- `/obelisk recap last week` -- the previous calendar week.
|
||||||
|
- `/obelisk recap this month` -- the current calendar month.
|
||||||
|
- `/obelisk recap last month` -- the previous calendar month.
|
||||||
|
|
||||||
|
Use the current date and timezone from the runtime/session context when
|
||||||
|
available. If the target contains extra style language, keep the period meaning
|
||||||
|
and treat the rest as presentation guidance.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
- Produce card content, not HTML, CSS, SVG, or layout instructions.
|
||||||
|
- Keep the first result share-safe. Avoid secrets, tokens, private URLs, long
|
||||||
|
absolute paths, raw tool outputs, and embarrassing private text unless the
|
||||||
|
user asked for private analysis.
|
||||||
|
- Concrete numbers, exact times, quotes, and verdicts need evidence. If you
|
||||||
|
cannot support a detail, omit it or soften it.
|
||||||
|
- Memory is prior notes, not ground truth. If a card uses a memory conclusion,
|
||||||
|
say in prose that it came from a prior memory when answering in chat, and add
|
||||||
|
`memory_id` evidence when producing JSON.
|
||||||
|
- Do not propose a durable memory just because you generated a recap. Propose
|
||||||
|
memory only if the retrieval reveals a reusable cross-session conclusion not
|
||||||
|
already covered by `memories()`.
|
||||||
|
|
||||||
|
## Retrieval Shape
|
||||||
|
|
||||||
|
Start with orientation and bounded evidence. Use English memory queries even
|
||||||
|
when the recap text will be in another language.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const period = {
|
||||||
|
label: 'Week 24',
|
||||||
|
after: '2026-06-08T00:00:00+08:00',
|
||||||
|
before: '2026-06-15T00:00:00+08:00',
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = overview({ limit: 8 });
|
||||||
|
const project = map.current.project?.project;
|
||||||
|
const scoped = project ? { project } : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: map.current,
|
||||||
|
current_project: map.current_project,
|
||||||
|
prior_memories: memories({
|
||||||
|
...scoped,
|
||||||
|
query: 'weekly recap project decisions workflows debugging shipping',
|
||||||
|
limit: 8,
|
||||||
|
}).map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
|
session_id: m.session_id,
|
||||||
|
created_at: m.created_at,
|
||||||
|
summary: m.summary?.slice(0, 280),
|
||||||
|
})),
|
||||||
|
sessions: sessions({
|
||||||
|
...scoped,
|
||||||
|
after: period.after,
|
||||||
|
before: period.before,
|
||||||
|
limit: 40,
|
||||||
|
}).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
project: s.project,
|
||||||
|
branch: s.git_branch,
|
||||||
|
started_at: s.started_at,
|
||||||
|
ended_at: s.ended_at,
|
||||||
|
message_count: s.message_count,
|
||||||
|
})),
|
||||||
|
summaries: summaries({
|
||||||
|
...scoped,
|
||||||
|
after: period.after,
|
||||||
|
before: period.before,
|
||||||
|
limit: 30,
|
||||||
|
}).map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
session_id: s.session_id,
|
||||||
|
session_title: s.session_title,
|
||||||
|
project: s.project,
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
snippet: s.content?.slice(0, 260),
|
||||||
|
})),
|
||||||
|
workflows: workflows({
|
||||||
|
...scoped,
|
||||||
|
after: period.after,
|
||||||
|
before: period.before,
|
||||||
|
limit: 20,
|
||||||
|
}).map(w => ({
|
||||||
|
run_id: w.run_id,
|
||||||
|
session_id: w.session_id,
|
||||||
|
name: w.workflow_name,
|
||||||
|
status: w.status,
|
||||||
|
agent_count: w.agent_count,
|
||||||
|
tokens: w.total_tokens,
|
||||||
|
timestamp: w.timestamp,
|
||||||
|
})),
|
||||||
|
failures: failures({
|
||||||
|
...scoped,
|
||||||
|
after: period.after,
|
||||||
|
before: period.before,
|
||||||
|
limit: 12,
|
||||||
|
}).map(f => ({
|
||||||
|
session_id: f.session_id,
|
||||||
|
session_title: f.session_title,
|
||||||
|
tool: f.tool_name,
|
||||||
|
timestamp: f.timestamp,
|
||||||
|
snippet: f.content?.slice(0, 180),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use SQL for exact aggregate numbers after the helper-first pass has established
|
||||||
|
the scope. Keep meta rows out of ordinary user-visible counts.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const project = '%quiet-zero%';
|
||||||
|
const after = '2026-06-08T00:00:00+08:00';
|
||||||
|
const before = '2026-06-15T00:00:00+08:00';
|
||||||
|
|
||||||
|
const metrics = sql(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT s.id) AS sessions,
|
||||||
|
COUNT(m.uuid) AS messages,
|
||||||
|
COALESCE(SUM(COALESCE(m.input_tokens, 0) + COALESCE(m.output_tokens, 0)), 0) AS tokens,
|
||||||
|
COUNT(DISTINCT substr(m.timestamp, 1, 10)) AS active_days
|
||||||
|
FROM sessions s
|
||||||
|
LEFT JOIN messages m ON m.session_id = s.id
|
||||||
|
WHERE s.project LIKE ?
|
||||||
|
AND COALESCE(m.is_meta, 0) = 0
|
||||||
|
AND m.timestamp >= ?
|
||||||
|
AND m.timestamp < ?
|
||||||
|
`, project, after, before)[0];
|
||||||
|
|
||||||
|
const user_messages = sql(`
|
||||||
|
SELECT
|
||||||
|
m.uuid,
|
||||||
|
m.session_id,
|
||||||
|
s.title AS session_title,
|
||||||
|
m.timestamp,
|
||||||
|
substr(m.text, 1, 500) AS text
|
||||||
|
FROM messages m
|
||||||
|
JOIN sessions s ON s.id = m.session_id
|
||||||
|
WHERE s.project LIKE ?
|
||||||
|
AND m.type = 'user'
|
||||||
|
AND m.content_type = 'text'
|
||||||
|
AND COALESCE(m.is_meta, 0) = 0
|
||||||
|
AND m.timestamp >= ?
|
||||||
|
AND m.timestamp < ?
|
||||||
|
ORDER BY m.timestamp
|
||||||
|
LIMIT 300
|
||||||
|
`, project, after, before);
|
||||||
|
|
||||||
|
return { metrics, user_messages };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archetypes
|
||||||
|
|
||||||
|
Choose one dominant archetype from the evidence. Do not force all cards to match
|
||||||
|
it; the archetype is the cover persona and tone baseline.
|
||||||
|
|
||||||
|
| archetype | essential action | tone baseline |
|
||||||
|
|---|---|---|
|
||||||
|
| `architect` | establishes structure, boundaries, and systems from above | matter-of-fact, structural pride |
|
||||||
|
| `debugger` | loops through symptoms until root cause becomes visible | wry, weary, occasional dark humor |
|
||||||
|
| `shipper` | keeps pushing forward with dense cadence | energetic, slightly breathless |
|
||||||
|
| `curator` | collects, organizes, refines, and distills | reflective, low-key clarity |
|
||||||
|
| `director` | coordinates many threads from the center outward | observant, slight remove |
|
||||||
|
| `cartographer` | reorganizes known structure, redraws maps, moves boundaries | patient, surveyor-like |
|
||||||
|
| `wanderer` | crosses projects without one obvious center, leaving traces | gentle, accepting, no apology |
|
||||||
|
|
||||||
|
Selection hints:
|
||||||
|
|
||||||
|
- Use `architect` when the week is dominated by system design, APIs, schemas,
|
||||||
|
boundaries, or core concepts.
|
||||||
|
- Use `debugger` when repeated failures, false positives, regressions, or root
|
||||||
|
cause hunts dominate.
|
||||||
|
- Use `shipper` when the evidence shows sustained implementation velocity,
|
||||||
|
builds, releases, or many completed edits.
|
||||||
|
- Use `curator` when the work is mostly cleanup, documentation, memory,
|
||||||
|
organization, or taste/refinement.
|
||||||
|
- Use `director` when workflows, subagents, review loops, or multi-agent
|
||||||
|
coordination are the main story.
|
||||||
|
- Use `cartographer` when the user moves modules, redraws information
|
||||||
|
architecture, re-scopes boundaries, or turns "this lives here" into "this
|
||||||
|
belongs there".
|
||||||
|
- Use `wanderer` when the period spans many unrelated projects and the honest
|
||||||
|
story is exploratory rather than centered.
|
||||||
|
|
||||||
|
## JSON Shape
|
||||||
|
|
||||||
|
The schema is deliberately card-content oriented. Keep keys stable, but let
|
||||||
|
strings carry the style. Card text can use the user's language; field names stay
|
||||||
|
English.
|
||||||
|
|
||||||
|
For weekly recaps, `metrics.active_days` and cover `activity` should contain 7
|
||||||
|
numbers ordered Monday through Sunday, where `0` means no visible activity and
|
||||||
|
`1` means active. For monthly recaps, use one value per calendar day in the
|
||||||
|
period, or omit the field if the evidence is too thin.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Recap = {
|
||||||
|
schema_version: "obelisk.recap.v1";
|
||||||
|
kind: "weekly" | "monthly";
|
||||||
|
generated_at: string;
|
||||||
|
|
||||||
|
period: {
|
||||||
|
label: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
source: {
|
||||||
|
project?: string;
|
||||||
|
session_ids: string[];
|
||||||
|
memory_ids?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
metrics: {
|
||||||
|
sessions?: number;
|
||||||
|
messages?: number;
|
||||||
|
tokens?: number;
|
||||||
|
active_days?: number[];
|
||||||
|
streak_days?: number;
|
||||||
|
workflows?: number;
|
||||||
|
workflow_agents?: number;
|
||||||
|
corrections?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
persona: {
|
||||||
|
archetype:
|
||||||
|
| "architect"
|
||||||
|
| "debugger"
|
||||||
|
| "shipper"
|
||||||
|
| "curator"
|
||||||
|
| "director"
|
||||||
|
| "cartographer"
|
||||||
|
| "wanderer";
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
cards: [
|
||||||
|
CoverCard,
|
||||||
|
ThinkingPathCard,
|
||||||
|
VibeCard,
|
||||||
|
WorkflowOrToolsCard,
|
||||||
|
ClosingCard
|
||||||
|
];
|
||||||
|
|
||||||
|
evidence?: Evidence[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Card 1: Cover
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CoverCard = {
|
||||||
|
type: "cover";
|
||||||
|
badge: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
activity: number[];
|
||||||
|
footer: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the cover to name the period and the persona. The title can be an archetype
|
||||||
|
label such as "The Architect"; the subtitle should summarize the period's real
|
||||||
|
dominant work in one line.
|
||||||
|
|
||||||
|
### Card 2: Thinking Path
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ThinkingPathCard = {
|
||||||
|
type: "thinking_path";
|
||||||
|
title: string;
|
||||||
|
items: Array<{
|
||||||
|
day: string;
|
||||||
|
prompt: string;
|
||||||
|
outcome: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick 3-6 turning points. A `prompt` is the question, friction, or task that
|
||||||
|
started the path. An `outcome` is the decision, fix, or learned framing.
|
||||||
|
|
||||||
|
### Card 3: Vibe
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type VibeCard = {
|
||||||
|
type: "vibe";
|
||||||
|
title: string;
|
||||||
|
observations: Array<{
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
count?: number;
|
||||||
|
time?: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
}>;
|
||||||
|
meter?: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
quote?: {
|
||||||
|
text: string;
|
||||||
|
caption?: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This card can be playful, but it must stay grounded. Repeated phrases should be
|
||||||
|
counted from visible user messages, not meta messages or tool results.
|
||||||
|
|
||||||
|
### Card 4: Workflows Or Tools
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type WorkflowOrToolsCard = {
|
||||||
|
type: "workflow" | "tool_habits" | "debugging" | "shipping";
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
stats?: string;
|
||||||
|
items: Array<{
|
||||||
|
name: string;
|
||||||
|
outcome: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
}>;
|
||||||
|
verdict: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Choose the card type that best fits the period. Use `workflow` when workflow
|
||||||
|
runs or subagents are the story; `debugging` when failures and fixes dominate;
|
||||||
|
`shipping` when completed implementation dominates; `tool_habits` when the
|
||||||
|
period is mostly about how the user worked.
|
||||||
|
|
||||||
|
### Card 5: Closing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ClosingCard = {
|
||||||
|
type: "closing";
|
||||||
|
headline: string;
|
||||||
|
stats: string[];
|
||||||
|
most_said_phrase?: string;
|
||||||
|
signoff: string;
|
||||||
|
evidence_refs?: string[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Close with one high-signal stat and a short signoff. Avoid turning the closing
|
||||||
|
card into a second summary.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Evidence = {
|
||||||
|
id: string;
|
||||||
|
session_id?: string;
|
||||||
|
message_uuid?: string;
|
||||||
|
memory_id?: string;
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Evidence is for local traceability and app inspection. It does not have to be
|
||||||
|
shown on exported cards. Prefer short summaries over raw snippets.
|
||||||
|
|
||||||
|
## Output Rules
|
||||||
|
|
||||||
|
- If the user asks for app handoff, return one JSON object and no surrounding
|
||||||
|
prose.
|
||||||
|
- If the user asks conversationally, answer with the card content naturally and
|
||||||
|
include JSON only if useful.
|
||||||
|
- Keep `cards.length === 5` in the order above.
|
||||||
|
- Do not include unsupported cards just to fill space. It is better to make a
|
||||||
|
quieter card than to invent drama.
|
||||||
|
- Do not include raw SQL, query scripts, or private evidence in the shareable
|
||||||
|
card text.
|
||||||
|
|
||||||
|
## Minimal Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "obelisk.recap.v1",
|
||||||
|
"kind": "weekly",
|
||||||
|
"generated_at": "2026-06-13T03:40:00+08:00",
|
||||||
|
"period": {
|
||||||
|
"label": "Week 24",
|
||||||
|
"start": "2026-06-08",
|
||||||
|
"end": "2026-06-14",
|
||||||
|
"timezone": "Asia/Shanghai"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"project": "quiet-zero",
|
||||||
|
"session_ids": ["sid-a", "sid-b"],
|
||||||
|
"memory_ids": ["mem-a"]
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"sessions": 12,
|
||||||
|
"messages": 847,
|
||||||
|
"tokens": 2400000,
|
||||||
|
"active_days": [1, 1, 1, 0, 1, 1, 1],
|
||||||
|
"workflows": 3,
|
||||||
|
"workflow_agents": 42
|
||||||
|
},
|
||||||
|
"persona": {
|
||||||
|
"archetype": "architect",
|
||||||
|
"title": "The Architect",
|
||||||
|
"subtitle": "Designed a memory system from raw sessions to durable notes.",
|
||||||
|
"tone": "matter-of-fact, structural pride"
|
||||||
|
},
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"badge": "Week 24",
|
||||||
|
"title": "The Architect",
|
||||||
|
"subtitle": "Designed a full memory layer without turning sessions into a wiki.",
|
||||||
|
"activity": [1, 1, 1, 0, 1, 1, 1],
|
||||||
|
"footer": "12 sessions - 2.4M tokens",
|
||||||
|
"evidence_refs": ["e1", "e2"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "thinking_path",
|
||||||
|
"title": "Your thinking path",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"day": "Mon",
|
||||||
|
"prompt": "Why compile sessions into a wiki?",
|
||||||
|
"outcome": "Kept raw SQLite as the evidence layer.",
|
||||||
|
"evidence_refs": ["e1"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vibe",
|
||||||
|
"title": "Your vibe this week",
|
||||||
|
"observations": [
|
||||||
|
{
|
||||||
|
"label": "Catchphrase",
|
||||||
|
"text": "This is too ugly.",
|
||||||
|
"count": 4,
|
||||||
|
"evidence_refs": ["e3"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meter": {
|
||||||
|
"label": "Patience",
|
||||||
|
"value": 0.8,
|
||||||
|
"caption": "saint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "workflow",
|
||||||
|
"title": "Are you a Workflow Enjoyer?",
|
||||||
|
"stats": "3 workflows - 42 agents",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "hono-plugin-review",
|
||||||
|
"outcome": "perfect",
|
||||||
|
"evidence_refs": ["e4"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verdict": "Mostly tolerated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "closing",
|
||||||
|
"headline": "19 day streak",
|
||||||
|
"stats": ["847 messages exchanged"],
|
||||||
|
"most_said_phrase": "Okay, start doing it.",
|
||||||
|
"signoff": "See you next week.",
|
||||||
|
"evidence_refs": ["e5"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"id": "e1",
|
||||||
|
"session_id": "sid-a",
|
||||||
|
"message_uuid": "msg-a",
|
||||||
|
"summary": "The user chose raw SQLite as the evidence layer."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -511,10 +511,6 @@ function createAttuneApi(db) {
|
|||||||
const created_at = new Date().toISOString();
|
const created_at = new Date().toISOString();
|
||||||
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run(
|
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run(
|
||||||
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at);
|
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at);
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO memories_fts(rowid, id, path, summary)
|
|
||||||
SELECT rowid, id, path, summary FROM memories WHERE id = ?
|
|
||||||
`).run(id);
|
|
||||||
return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at };
|
return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ CREATE TABLE IF NOT EXISTS summaries (
|
|||||||
source TEXT, content TEXT);
|
source TEXT, content TEXT);
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||||
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
|
uuid UNINDEXED, session_id UNINDEXED, text, content=messages, content_rowid=rowid);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(rowid, uuid, session_id, text)
|
||||||
|
VALUES (new.rowid, new.uuid, new.session_id, new.text);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
|
||||||
|
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN
|
||||||
|
INSERT INTO messages_fts(messages_fts, rowid, uuid, session_id, text)
|
||||||
|
VALUES ('delete', old.rowid, old.uuid, old.session_id, old.text);
|
||||||
|
INSERT INTO messages_fts(rowid, uuid, session_id, text)
|
||||||
|
VALUES (new.rowid, new.uuid, new.session_id, new.text);
|
||||||
|
END;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
|
CREATE INDEX IF NOT EXISTS idx_messages_agent ON messages(agent_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
|
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(session_id, timestamp);
|
||||||
@@ -52,6 +66,20 @@ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|||||||
id UNINDEXED, path, summary,
|
id UNINDEXED, path, summary,
|
||||||
content=memories, content_rowid=rowid,
|
content=memories, content_rowid=rowid,
|
||||||
tokenize='unicode61 remove_diacritics 1');
|
tokenize='unicode61 remove_diacritics 1');
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(rowid, id, path, summary)
|
||||||
|
VALUES (new.rowid, new.id, new.path, new.summary);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, id, path, summary)
|
||||||
|
VALUES ('delete', old.rowid, old.id, old.path, old.summary);
|
||||||
|
INSERT INTO memories_fts(rowid, id, path, summary)
|
||||||
|
VALUES (new.rowid, new.id, new.path, new.summary);
|
||||||
|
END;
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
||||||
|
|||||||
Reference in New Issue
Block a user