feat(activity): add contribution ledger
This commit is contained in:
@@ -10,6 +10,7 @@ import { createIndexerService } from './indexer-service.ts';
|
|||||||
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
import { createWorkerBuildIndex } from './indexer-worker-client.ts';
|
||||||
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
import { buildRecapExportQuery } from './recap-capture-query.ts';
|
||||||
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts';
|
||||||
|
import type { SourceQueryOptions } from '../shared/ipc-types.ts';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -220,8 +221,8 @@ function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceWhereClause(opts: { includeCodex?: boolean; source?: string } = {}, column = "source"): { sql: string; params: unknown[] } {
|
function sourceWhereClause(opts: SourceQueryOptions = {}, column = "source"): { sql: string; params: unknown[] } {
|
||||||
if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] };
|
if (opts.source === 'all') return { sql: '', params: [] };
|
||||||
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
|
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
|
||||||
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
|
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
|
||||||
|
import type { UsageStatsOptions } from '../shared/ipc-types.ts';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('obelisk', {
|
contextBridge.exposeInMainWorld('obelisk', {
|
||||||
getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts),
|
getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts),
|
||||||
@@ -18,7 +19,7 @@ contextBridge.exposeInMainWorld('obelisk', {
|
|||||||
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
|
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||||
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||||
getStats: () => ipcRenderer.invoke('db:getStats'),
|
getStats: () => ipcRenderer.invoke('db:getStats'),
|
||||||
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
getUsageStats: (opts?: UsageStatsOptions) => ipcRenderer.invoke('db:getUsageStats', opts),
|
||||||
onIndexUpdated: (callback: (payload: unknown) => void) => {
|
onIndexUpdated: (callback: (payload: unknown) => void) => {
|
||||||
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
|
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
|
||||||
ipcRenderer.on('obelisk:index-updated', listener);
|
ipcRenderer.on('obelisk:index-updated', listener);
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export function activitySourceKey(session) {
|
||||||
|
const source = typeof session?.source === 'string' ? session.source.trim().toLowerCase() : '';
|
||||||
|
return source || 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activitySourceLabel(session) {
|
||||||
|
const source = activitySourceKey(session);
|
||||||
|
if (source === 'codex') return 'Codex';
|
||||||
|
if (source === 'claude') return 'Claude Code';
|
||||||
|
return source.charAt(0).toUpperCase() + source.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityGroupSessions(split) {
|
||||||
|
return [...(split?.normal || []), ...(split?.noise || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activityGroupHasMixedSources(split) {
|
||||||
|
return new Set(activityGroupSessions(split).map(activitySourceKey)).size > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activitySessionMetaParts(session, {
|
||||||
|
mixedSources = false,
|
||||||
|
projectLabel = '',
|
||||||
|
includeProject = true,
|
||||||
|
} = {}) {
|
||||||
|
const parts = [];
|
||||||
|
if (mixedSources) parts.push({ kind: 'source', text: activitySourceLabel(session) });
|
||||||
|
if (includeProject && projectLabel) parts.push({ kind: 'project', text: projectLabel });
|
||||||
|
parts.push({
|
||||||
|
kind: 'count',
|
||||||
|
text: `${Number(session?.message_count || 0).toLocaleString('en-US')} msg`,
|
||||||
|
});
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
import {
|
||||||
|
activityGroupHasMixedSources,
|
||||||
|
activityGroupSessions,
|
||||||
|
} from '../activity-ledger.mjs';
|
||||||
|
import ActivityLedgerRow from './ActivityLedgerRow.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
block: { type: Object, required: true },
|
||||||
|
eventDate: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-session']);
|
||||||
|
const expanded = reactive({});
|
||||||
|
|
||||||
|
function plural(count, singular, pluralForm = `${singular}s`) {
|
||||||
|
return count === 1 ? singular : pluralForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectCount(split) {
|
||||||
|
return new Set(activityGroupSessions(split).map(session => session.project || '(none)')).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'workspaces',
|
||||||
|
kind: 'workspace',
|
||||||
|
split: props.block.newWorkspaces,
|
||||||
|
title: `Created ${props.block.newWorkspaces.total} new ${plural(props.block.newWorkspaces.total, 'workspace')}`,
|
||||||
|
includeProject: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sessions',
|
||||||
|
kind: 'started',
|
||||||
|
split: props.block.newSessions,
|
||||||
|
title: `Started ${props.block.newSessions.total} ${plural(props.block.newSessions.total, 'session')} in ${projectCount(props.block.newSessions)} ${plural(projectCount(props.block.newSessions), 'project')}`,
|
||||||
|
includeProject: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'continued',
|
||||||
|
kind: 'continued',
|
||||||
|
split: props.block.continued,
|
||||||
|
title: `Continued ${props.block.continued.total} ${plural(props.block.continued.total, 'session')}`,
|
||||||
|
includeProject: true,
|
||||||
|
},
|
||||||
|
].filter(group => group.split.total > 0).map(group => ({
|
||||||
|
...group,
|
||||||
|
mixedSources: activityGroupHasMixedSources(group.split),
|
||||||
|
})));
|
||||||
|
|
||||||
|
function toggleNoise(key) {
|
||||||
|
expanded[key] = !expanded[key];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="activity-ledger" v-if="groups.length">
|
||||||
|
<article
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.key"
|
||||||
|
class="ledger-group"
|
||||||
|
:class="group.kind"
|
||||||
|
>
|
||||||
|
<div class="ledger-node" aria-hidden="true">
|
||||||
|
<svg v-if="group.kind === 'workspace'" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="group.kind === 'started'" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24">
|
||||||
|
<path d="m17 2 4 4-4 4"/>
|
||||||
|
<path d="M3 11v-1a4 4 0 0 1 4-4h14"/>
|
||||||
|
<path d="m7 22-4-4 4-4"/>
|
||||||
|
<path d="M21 13v1a4 4 0 0 1-4 4H3"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="ledger-group-header">
|
||||||
|
<h3>{{ group.title }}</h3>
|
||||||
|
<time v-if="eventDate">{{ eventDate }}</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ledger-items">
|
||||||
|
<ActivityLedgerRow
|
||||||
|
v-for="session in group.split.normal"
|
||||||
|
:key="session.id"
|
||||||
|
:session="session"
|
||||||
|
:mixed-sources="group.mixedSources"
|
||||||
|
:include-project="group.includeProject"
|
||||||
|
:tone="group.kind"
|
||||||
|
@open="emit('open-session', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="group.split.noise.length">
|
||||||
|
<button
|
||||||
|
class="noise-fold-row"
|
||||||
|
:class="{ expanded: expanded[group.key] }"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="Boolean(expanded[group.key])"
|
||||||
|
@click="toggleNoise(group.key)"
|
||||||
|
>
|
||||||
|
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
||||||
|
<span>{{ group.split.noise.length }} hidden, likely test or throwaway runs</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ActivityLedgerRow
|
||||||
|
v-for="session in (expanded[group.key] ? group.split.noise : [])"
|
||||||
|
:key="session.id"
|
||||||
|
:session="session"
|
||||||
|
:mixed-sources="group.mixedSources"
|
||||||
|
:include-project="group.includeProject"
|
||||||
|
:tone="group.kind"
|
||||||
|
noise
|
||||||
|
@open="emit('open-session', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activity-ledger {
|
||||||
|
position: relative;
|
||||||
|
margin-left: 16px;
|
||||||
|
padding-left: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-ledger::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 15px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-group {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-group:last-child { padding-bottom: 6px; }
|
||||||
|
|
||||||
|
.ledger-node {
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: -58px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
box-shadow: 0 0 0 5px var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-node svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace .ledger-node {
|
||||||
|
color: #f59e0b;
|
||||||
|
border-color: rgba(245, 158, 11, .28);
|
||||||
|
background: rgba(245, 158, 11, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.started .ledger-node {
|
||||||
|
color: var(--accent-2);
|
||||||
|
border-color: rgba(167, 139, 250, .28);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-group-header {
|
||||||
|
min-height: 27px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-group-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-group-header time {
|
||||||
|
color: var(--muted-2);
|
||||||
|
font: 10px/1 var(--font-mono);
|
||||||
|
letter-spacing: .05em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise-fold-row {
|
||||||
|
width: fit-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 5px 0 0 -4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font: 10.5px/1.25 var(--font-mono);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise-fold-row:hover { color: var(--muted); background: var(--surface-strong); }
|
||||||
|
|
||||||
|
.noise-fold-row .chev {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform .15s cubic-bezier(.22, 1, .36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.noise-fold-row.expanded .chev { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.activity-ledger { margin-left: 8px; padding-left: 46px; }
|
||||||
|
.ledger-node { left: -46px; }
|
||||||
|
.ledger-group-header { gap: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { formatProjectLabel } from '../utils.js';
|
||||||
|
import { activitySessionMetaParts } from '../activity-ledger.mjs';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
session: { type: Object, required: true },
|
||||||
|
mixedSources: { type: Boolean, default: false },
|
||||||
|
includeProject: { type: Boolean, default: true },
|
||||||
|
tone: { type: String, default: 'started' },
|
||||||
|
noise: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['open']);
|
||||||
|
|
||||||
|
const metaParts = computed(() => activitySessionMetaParts(props.session, {
|
||||||
|
mixedSources: props.mixedSources,
|
||||||
|
projectLabel: formatProjectLabel(props.session.project) || '',
|
||||||
|
includeProject: props.includeProject,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="ledger-item"
|
||||||
|
:class="[tone, { noise }]"
|
||||||
|
type="button"
|
||||||
|
@click="emit('open', session.id)"
|
||||||
|
>
|
||||||
|
<span class="ledger-item-title">{{ session.title || '(untitled)' }}</span>
|
||||||
|
<span class="ledger-item-meta">
|
||||||
|
<template v-for="(part, index) in metaParts" :key="`${part.kind}-${index}`">
|
||||||
|
<span v-if="index" class="meta-separator">·</span>
|
||||||
|
<span :class="`meta-${part.kind}`">{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-item {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
margin-left: -12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .14s cubic-bezier(.22, 1, .36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-item:hover { background: var(--surface-strong); }
|
||||||
|
|
||||||
|
.ledger-item-title {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 520;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-item.continued .ledger-item-title { color: var(--fg-2); }
|
||||||
|
.ledger-item:hover .ledger-item-title { text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
.ledger-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 15px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font: 10.5px/1.35 var(--font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-source {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-weight: 620;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-project { color: var(--muted); }
|
||||||
|
.meta-count { color: var(--muted-2); }
|
||||||
|
.meta-separator { color: var(--muted); font-weight: 700; opacity: .82; }
|
||||||
|
|
||||||
|
.ledger-item.noise { opacity: .7; }
|
||||||
|
.ledger-item.noise .ledger-item-title { color: var(--fg-2); font-weight: 430; }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.ledger-item-meta { flex-wrap: wrap; row-gap: 4px; white-space: normal; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { state } from '../store.js';
|
import { state } from '../store.js';
|
||||||
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
||||||
|
import ActivityLedger from '../components/ActivityLedger.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'Activity' });
|
defineOptions({ name: 'Activity' });
|
||||||
|
|
||||||
@@ -14,7 +15,6 @@ const loading = ref(true);
|
|||||||
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
||||||
const selectedDayKey = ref(null);
|
const selectedDayKey = ref(null);
|
||||||
const loadedMonths = ref(0);
|
const loadedMonths = ref(0);
|
||||||
const monthBlocks = ref([]);
|
|
||||||
|
|
||||||
// Tooltip
|
// Tooltip
|
||||||
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
||||||
@@ -37,11 +37,6 @@ function splitNoise(arr) {
|
|||||||
return { normal, noise, total: normal.length + noise.length };
|
return { normal, noise, total: normal.length + noise.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandedNoise = reactive({});
|
|
||||||
function toggleNoise(key) {
|
|
||||||
expandedNoise[key] = !expandedNoise[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function localDateStr(d) {
|
function localDateStr(d) {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -250,7 +245,9 @@ const daySessions = computed(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dateKey,
|
dateKey,
|
||||||
dateLabel: fmtTooltipDate(dateKey),
|
header: `${MONTHS_FULL[Number(dateKey.slice(5, 7)) - 1]} ${dateKey.slice(0, 4)}`,
|
||||||
|
eventDate: `${MONTHS_SHORT[Number(dateKey.slice(5, 7)) - 1].toUpperCase()} ${Number(dateKey.slice(8, 10))}`,
|
||||||
|
sessionTotal: classified.length,
|
||||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||||
continued: classified.filter(s => s.kind === 'continued'),
|
continued: classified.filter(s => s.kind === 'continued'),
|
||||||
@@ -269,13 +266,17 @@ const daySessionsSplit = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const monthBlocksSplit = computed(() =>
|
const monthBlocksSplit = computed(() =>
|
||||||
monthBlocks.value.map((b, bi) => ({
|
Array.from({ length: loadedMonths.value }, (_, offset) => {
|
||||||
...b,
|
const today = new Date();
|
||||||
bi,
|
const targetDate = new Date(today.getFullYear(), today.getMonth() - offset, 1);
|
||||||
newWorkspaces: splitNoise(b.newWorkspaces),
|
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
||||||
newSessions: splitNoise(b.newSessions),
|
return {
|
||||||
continued: splitNoise(b.continued),
|
...block,
|
||||||
}))
|
newWorkspaces: splitNoise(block.newWorkspaces),
|
||||||
|
newSessions: splitNoise(block.newSessions),
|
||||||
|
continued: splitNoise(block.continued),
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
@@ -349,6 +350,7 @@ function buildMonthBlock(year, month) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
header: `${MONTHS_FULL[month]} ${year}`,
|
header: `${MONTHS_FULL[month]} ${year}`,
|
||||||
|
sessionTotal: classified.length,
|
||||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||||
continued: classified.filter(s => s.kind === 'continued'),
|
continued: classified.filter(s => s.kind === 'continued'),
|
||||||
@@ -357,26 +359,12 @@ function buildMonthBlock(year, month) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showNextMonth() {
|
function showNextMonth() {
|
||||||
const today = new Date();
|
|
||||||
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
|
|
||||||
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
|
||||||
monthBlocks.value.push(block);
|
|
||||||
loadedMonths.value++;
|
loadedMonths.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectLabel(project) {
|
async function loadUsageStats() {
|
||||||
return formatProjectLabel(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
function newSessionProjectCount(sessions) {
|
|
||||||
const projects = new Set(sessions.map(s => s.project || '(none)'));
|
|
||||||
return projects.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lifecycle ---
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
try {
|
||||||
const data = await window.obelisk.getUsageStats();
|
const data = await window.obelisk.getUsageStats({ source: 'all' });
|
||||||
usageData.daily = data.daily || [];
|
usageData.daily = data.daily || [];
|
||||||
usageData.totalTokens = data.totalTokens || 0;
|
usageData.totalTokens = data.totalTokens || 0;
|
||||||
usageData.peakDay = data.peakDay || null;
|
usageData.peakDay = data.peakDay || null;
|
||||||
@@ -384,9 +372,21 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load usage stats:', e);
|
console.error('Failed to load usage stats:', e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
let stopUsageUpdates = () => {};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
stopUsageUpdates = window.obelisk?.onIndexUpdated?.(() => {
|
||||||
|
void loadUsageStats();
|
||||||
|
}) || (() => {});
|
||||||
|
await loadUsageStats();
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
showNextMonth();
|
showNextMonth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => stopUsageUpdates());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -544,230 +544,42 @@ onMounted(async () => {
|
|||||||
<div v-else class="empty">No data</div>
|
<div v-else class="empty">No data</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Day sessions panel (from heatmap click) -->
|
<!-- Session activity ledger -->
|
||||||
<div class="day-sessions" v-if="daySessionsSplit">
|
<section class="session-activity" v-if="daySessionsSplit">
|
||||||
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> — no sessions</template></div>
|
<div class="activity-month-heading">
|
||||||
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
|
<h2>{{ daySessionsSplit.header }}</h2>
|
||||||
<!-- New workspaces -->
|
<span class="activity-month-rule"></span>
|
||||||
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total">
|
<span class="activity-month-count">{{ daySessionsSplit.sessionTotal }} session{{ daySessionsSplit.sessionTotal === 1 ? '' : 's' }}</span>
|
||||||
<div class="activity-group-header">
|
|
||||||
<span class="activity-icon workspace">★</span>
|
|
||||||
<span class="activity-group-title">Created {{ daySessionsSplit.newWorkspaces.total }} new workspace{{ daySessionsSplit.newWorkspaces.total > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group-items">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.newWorkspaces.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="daySessionsSplit.newWorkspaces.noise.length">
|
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-workspaces'] }" @click="toggleNoise('day-workspaces')">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ daySessionsSplit.newWorkspaces.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise['day-workspaces']">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.newWorkspaces.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- New sessions -->
|
|
||||||
<div class="activity-group" v-if="daySessionsSplit.newSessions.total">
|
|
||||||
<div class="activity-group-header">
|
|
||||||
<span class="activity-icon new">+</span>
|
|
||||||
<span class="activity-group-title">Started {{ daySessionsSplit.newSessions.total }} session{{ daySessionsSplit.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) }} project{{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group-items">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.newSessions.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="daySessionsSplit.newSessions.noise.length">
|
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-sessions'] }" @click="toggleNoise('day-sessions')">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ daySessionsSplit.newSessions.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise['day-sessions']">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.newSessions.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Continued sessions -->
|
|
||||||
<div class="activity-group continued" v-if="daySessionsSplit.continued.total">
|
|
||||||
<div class="activity-group-header">
|
|
||||||
<span class="activity-icon continued">↳</span>
|
|
||||||
<span class="activity-group-title">Continued {{ daySessionsSplit.continued.total }} session{{ daySessionsSplit.continued.total > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group-items">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.continued.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="daySessionsSplit.continued.noise.length">
|
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-continued'] }" @click="toggleNoise('day-continued')">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ daySessionsSplit.continued.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise['day-continued']">
|
|
||||||
<button
|
|
||||||
v-for="s in daySessionsSplit.continued.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ActivityLedger
|
||||||
|
v-if="!daySessionsSplit.isEmpty"
|
||||||
|
:block="daySessionsSplit"
|
||||||
|
:event-date="daySessionsSplit.eventDate"
|
||||||
|
@open-session="goToSession"
|
||||||
|
/>
|
||||||
|
<div v-else class="activity-empty">No sessions on {{ daySessionsSplit.eventDate }}.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Monthly activity blocks -->
|
<section class="session-activity" v-else>
|
||||||
<div class="day-sessions" v-if="!selectedDayKey">
|
<section
|
||||||
<template v-for="(block, bi) in monthBlocksSplit" :key="bi">
|
v-for="block in monthBlocksSplit"
|
||||||
<div class="day-sessions-header">{{ block.header }}</div>
|
:key="block.header"
|
||||||
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
class="activity-month-block"
|
||||||
<div class="activity-group" v-if="block.newWorkspaces.total">
|
|
||||||
<div class="activity-group-header">
|
|
||||||
<span class="activity-icon workspace">★</span>
|
|
||||||
<span class="activity-group-title">Created {{ block.newWorkspaces.total }} new workspace{{ block.newWorkspaces.total > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group-items">
|
|
||||||
<button
|
|
||||||
v-for="s in block.newWorkspaces.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
>
|
||||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
<div class="activity-month-heading">
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
|
<h2>{{ block.header }}</h2>
|
||||||
</button>
|
<span class="activity-month-rule"></span>
|
||||||
<template v-if="block.newWorkspaces.noise.length">
|
<span class="activity-month-count">{{ block.sessionTotal }} session{{ block.sessionTotal === 1 ? '' : 's' }}</span>
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-workspaces`] }" @click="toggleNoise(`m${bi}-workspaces`)">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ block.newWorkspaces.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise[`m${bi}-workspaces`]">
|
|
||||||
<button
|
|
||||||
v-for="s in block.newWorkspaces.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ActivityLedger
|
||||||
<div class="activity-group" v-if="block.newSessions.total">
|
v-if="!block.isEmpty"
|
||||||
<div class="activity-group-header">
|
:block="block"
|
||||||
<span class="activity-icon new">+</span>
|
@open-session="goToSession"
|
||||||
<span class="activity-group-title">Started {{ block.newSessions.total }} session{{ block.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) }} project{{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) > 1 ? 's' : '' }}</span>
|
/>
|
||||||
</div>
|
<div v-else class="activity-empty">No sessions this month.</div>
|
||||||
<div class="activity-group-items">
|
</section>
|
||||||
<button
|
|
||||||
v-for="s in block.newSessions.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="block.newSessions.noise.length">
|
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-sessions`] }" @click="toggleNoise(`m${bi}-sessions`)">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ block.newSessions.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise[`m${bi}-sessions`]">
|
|
||||||
<button
|
|
||||||
v-for="s in block.newSessions.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group continued" v-if="block.continued.total">
|
|
||||||
<div class="activity-group-header">
|
|
||||||
<span class="activity-icon continued">↳</span>
|
|
||||||
<span class="activity-group-title">Continued {{ block.continued.total }} session{{ block.continued.total > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="activity-group-items">
|
|
||||||
<button
|
|
||||||
v-for="s in block.continued.normal"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="block.continued.noise.length">
|
|
||||||
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-continued`] }" @click="toggleNoise(`m${bi}-continued`)">
|
|
||||||
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
|
|
||||||
<span>{{ block.continued.noise.length }} hidden — likely test or throwaway runs</span>
|
|
||||||
</button>
|
|
||||||
<template v-if="expandedNoise[`m${bi}-continued`]">
|
|
||||||
<button
|
|
||||||
v-for="s in block.continued.noise"
|
|
||||||
:key="s.id"
|
|
||||||
class="activity-item noise"
|
|
||||||
@click="goToSession(s.id)"
|
|
||||||
>
|
|
||||||
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
|
||||||
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
|
|
||||||
</template>
|
|
||||||
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
|
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div
|
<div
|
||||||
@@ -861,96 +673,55 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.chart-tooltip.show { opacity: 1; }
|
.chart-tooltip.show { opacity: 1; }
|
||||||
|
|
||||||
/* Day sessions panel */
|
/* Session activity ledger */
|
||||||
.day-sessions { margin-top: 24px; }
|
.session-activity { margin-top: 34px; }
|
||||||
.day-sessions-header {
|
.activity-month-block { margin-bottom: 54px; }
|
||||||
font-size: 14px; font-weight: 600; color: var(--fg);
|
|
||||||
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
|
||||||
border-bottom: 1px solid var(--hairline);
|
|
||||||
}
|
|
||||||
.day-sessions-header:first-child { margin-top: 0; }
|
|
||||||
|
|
||||||
.day-activity-timeline {
|
.activity-month-heading {
|
||||||
display: flex; flex-direction: column; gap: 20px;
|
display: grid;
|
||||||
padding-left: 16px; border-left: 2px solid var(--hairline);
|
grid-template-columns: max-content minmax(48px, 1fr) max-content;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-group { position: relative; }
|
.activity-month-heading h2 {
|
||||||
.activity-group-header {
|
margin: 0;
|
||||||
display: flex; align-items: center; gap: 10px;
|
color: var(--fg);
|
||||||
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 650;
|
||||||
|
letter-spacing: -.01em;
|
||||||
}
|
}
|
||||||
.activity-icon {
|
|
||||||
width: 24px; height: 24px; border-radius: 50%;
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 12px; flex-shrink: 0;
|
|
||||||
margin-left: -28px;
|
|
||||||
border: 2px solid var(--bg);
|
|
||||||
}
|
|
||||||
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
|
||||||
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
|
||||||
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
|
||||||
|
|
||||||
.activity-group-title { font-size: 13px; }
|
.activity-month-rule { height: 1px; background: var(--hairline); }
|
||||||
.activity-group.continued .activity-group-title { color: var(--muted); }
|
.activity-month-count {
|
||||||
|
color: var(--muted-2);
|
||||||
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
font: 10px/1 var(--font-mono);
|
||||||
.activity-item {
|
letter-spacing: .04em;
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
white-space: nowrap;
|
||||||
padding: 8px 12px; border-radius: 5px;
|
|
||||||
background: transparent; border: 0;
|
|
||||||
cursor: pointer; transition: background 0.08s;
|
|
||||||
text-align: left; width: 100%;
|
|
||||||
font: inherit; color: inherit;
|
|
||||||
}
|
}
|
||||||
.activity-item:hover { background: var(--surface-strong); }
|
|
||||||
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.activity-empty {
|
||||||
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
padding: 4px 0 30px 74px;
|
||||||
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
|
||||||
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
|
|
||||||
.activity-item-meta .src-tag {
|
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
|
||||||
font-size: 10px; letter-spacing: 0.02em;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.activity-item-meta .src-tag .src-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
|
||||||
.activity-item-meta .src-tag.claude .src-dot { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
|
|
||||||
.activity-item-meta .src-tag.codex .src-dot { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
|
|
||||||
.activity-item-meta .src-tag.claude { color: #d97757; }
|
|
||||||
.activity-item-meta .src-tag.codex { color: #10a37f; }
|
|
||||||
|
|
||||||
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
|
||||||
|
|
||||||
.noise-fold-row {
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
padding: 6px 12px; margin-top: 2px;
|
|
||||||
border-radius: 5px; background: transparent; border: 0;
|
|
||||||
cursor: pointer; transition: background 0.08s;
|
|
||||||
text-align: left; width: 100%; font: inherit;
|
|
||||||
color: var(--muted); font-size: 11.5px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
.noise-fold-row:hover { background: var(--surface-strong); color: var(--fg-2); }
|
|
||||||
.noise-fold-row .chev {
|
|
||||||
width: 9px; height: 9px; color: var(--muted-2);
|
|
||||||
transition: transform 0.15s; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.noise-fold-row.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
|
|
||||||
|
|
||||||
.activity-item.noise .activity-item-name {
|
|
||||||
color: var(--fg-2); font-style: italic; font-weight: 400;
|
|
||||||
}
|
|
||||||
.activity-item.noise .activity-item-project { color: var(--muted); }
|
|
||||||
|
|
||||||
.show-more-btn {
|
.show-more-btn {
|
||||||
display: block; width: 100%; margin-top: 20px;
|
display: block;
|
||||||
padding: 8px; border-radius: 4px;
|
width: fit-content;
|
||||||
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
margin: -18px auto 16px;
|
||||||
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
padding: 8px 12px;
|
||||||
cursor: pointer; transition: all 0.1s; text-align: center;
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: 11px/1 var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .12s, background .12s, border-color .12s;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
.show-more-btn:hover { color: var(--fg-2); background: var(--surface-strong); border-color: var(--hairline-strong); }
|
||||||
|
|
||||||
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface SourceQueryOptions {
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageStatsOptions = SourceQueryOptions;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
activityGroupHasMixedSources,
|
||||||
|
activitySessionMetaParts,
|
||||||
|
activitySourceLabel,
|
||||||
|
} from '../app/src/renderer/src/activity-ledger.mjs';
|
||||||
|
|
||||||
|
const claudeSession = {
|
||||||
|
source: 'claude',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_count: 2716,
|
||||||
|
};
|
||||||
|
const codexSession = {
|
||||||
|
source: 'codex',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_count: 2052,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('single-source activity groups omit provider provenance', () => {
|
||||||
|
const split = { normal: [codexSession], noise: [{ ...codexSession, message_count: 12 }] };
|
||||||
|
|
||||||
|
assert.equal(activityGroupHasMixedSources(split), false);
|
||||||
|
assert.deepEqual(
|
||||||
|
activitySessionMetaParts(codexSession, {
|
||||||
|
mixedSources: false,
|
||||||
|
projectLabel: 'quiet-zero',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{ kind: 'project', text: 'quiet-zero' },
|
||||||
|
{ kind: 'count', text: '2,052 msg' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mixed activity groups expose provider before project and count', () => {
|
||||||
|
const split = { normal: [codexSession], noise: [claudeSession] };
|
||||||
|
|
||||||
|
assert.equal(activityGroupHasMixedSources(split), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
activitySessionMetaParts(claudeSession, {
|
||||||
|
mixedSources: true,
|
||||||
|
projectLabel: 'quiet-zero',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{ kind: 'source', text: 'Claude Code' },
|
||||||
|
{ kind: 'project', text: 'quiet-zero' },
|
||||||
|
{ kind: 'count', text: '2,716 msg' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace activity omits redundant project scope', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
activitySessionMetaParts(codexSession, {
|
||||||
|
mixedSources: false,
|
||||||
|
projectLabel: 'quiet-zero',
|
||||||
|
includeProject: false,
|
||||||
|
}),
|
||||||
|
[{ kind: 'count', text: '2,052 msg' }],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown providers retain their own provenance label', () => {
|
||||||
|
assert.equal(activitySourceLabel({ source: 'opencode' }), 'Opencode');
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const preloadUrl = new URL('../app/src/preload/index.ts', import.meta.url);
|
||||||
|
const preloadDir = fileURLToPath(new URL('.', preloadUrl));
|
||||||
|
|
||||||
|
function esmResolve(specifier) {
|
||||||
|
return execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
['--input-type=module', '-e', `process.stdout.write(import.meta.resolve(${JSON.stringify(specifier)}))`],
|
||||||
|
{ cwd: preloadDir, encoding: 'utf8' },
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Activity requests usage across all indexed providers', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/views/Activity.vue', import.meta.url), 'utf8');
|
||||||
|
assert.match(source, /getUsageStats\(\{\s*source:\s*['"]all['"]\s*\}\)/);
|
||||||
|
assert.match(source, /onIndexUpdated\?\.\(\(\)\s*=>\s*\{?\s*(?:void\s+)?loadUsageStats\(\)/s);
|
||||||
|
assert.match(source, /Array\.from\(\{\s*length:\s*loadedMonths\.value\s*\}/);
|
||||||
|
assert.doesNotMatch(source, /monthBlocks\s*=\s*ref\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preload forwards usage source options to the main process', async () => {
|
||||||
|
const calls = [];
|
||||||
|
let api;
|
||||||
|
const electron = mock.module(esmResolve('electron'), {
|
||||||
|
namedExports: {
|
||||||
|
contextBridge: {
|
||||||
|
exposeInMainWorld(_name, exposedApi) {
|
||||||
|
api = exposedApi;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ipcRenderer: {
|
||||||
|
invoke(...args) {
|
||||||
|
calls.push(args);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
on() {},
|
||||||
|
removeListener() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await import(`${preloadUrl.href}?activity-usage=${Date.now()}`);
|
||||||
|
await api.getUsageStats({ source: 'all' });
|
||||||
|
assert.deepEqual(calls.at(-1), ['db:getUsageStats', { source: 'all' }]);
|
||||||
|
} finally {
|
||||||
|
electron.restore();
|
||||||
|
mock.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
|||||||
ipcHandlers.get('db:getProjects')(null, {});
|
ipcHandlers.get('db:getProjects')(null, {});
|
||||||
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
||||||
|
|
||||||
ipcHandlers.get('db:getSessions')(null, { includeCodex: true });
|
ipcHandlers.get('db:getSessions')(null, { source: 'all' });
|
||||||
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
||||||
|
|
||||||
ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
|
ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
|
||||||
@@ -426,6 +426,79 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('usage IPC aggregates normalized tokens across all indexed providers', async () => {
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
const home = join(tmpdir(), `obelisk-main-usage-${Date.now()}`);
|
||||||
|
const obeliskDir = join(home, '.obelisk');
|
||||||
|
mkdirSync(obeliskDir, { recursive: true });
|
||||||
|
process.env.HOME = home;
|
||||||
|
|
||||||
|
const dbPath = join(obeliskDir, 'obelisk.sqlite');
|
||||||
|
const setup = new DatabaseSync(dbPath);
|
||||||
|
setup.exec(readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8'));
|
||||||
|
setup.prepare(`
|
||||||
|
INSERT INTO messages (
|
||||||
|
uuid, session_id, type, timestamp, role, text,
|
||||||
|
input_tokens, output_tokens, source
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run('claude-message', 'claude-session', 'assistant', '2026-07-10T10:00:00Z', 'assistant', 'ok', 60, 5, 'claude');
|
||||||
|
setup.prepare(`
|
||||||
|
INSERT INTO messages (
|
||||||
|
uuid, session_id, type, timestamp, role, text,
|
||||||
|
input_tokens, output_tokens, source
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex');
|
||||||
|
setup.close();
|
||||||
|
|
||||||
|
const ipcHandlers = new Map();
|
||||||
|
|
||||||
|
class FakeBrowserWindow {
|
||||||
|
constructor() {
|
||||||
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||||
|
}
|
||||||
|
loadFile() {}
|
||||||
|
loadURL() {}
|
||||||
|
close() {}
|
||||||
|
static getAllWindows() { return []; }
|
||||||
|
static fromWebContents() { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = registerMocks([
|
||||||
|
[ELECTRON_URL, {
|
||||||
|
namedExports: electronNamespace({
|
||||||
|
BrowserWindow: FakeBrowserWindow,
|
||||||
|
ipcMain: {
|
||||||
|
handle(channel, handler) {
|
||||||
|
ipcHandlers.set(channel, handler);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
[DATABASE_URL, { defaultExport: SqliteCompatDatabase }],
|
||||||
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
||||||
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
||||||
|
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
|
||||||
|
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importMain();
|
||||||
|
|
||||||
|
const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {});
|
||||||
|
assert.equal(claudeOnly.totalTokens, 65);
|
||||||
|
assert.equal(claudeOnly.daily[0].tokens, 65);
|
||||||
|
|
||||||
|
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
|
||||||
|
assert.equal(allSources.totalTokens, 175);
|
||||||
|
assert.equal(allSources.daily[0].tokens, 175);
|
||||||
|
assert.equal(allSources.peakDay.tokens, 175);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('main process migrates an existing app database before source-filtered IPC queries', async () => {
|
test('main process migrates an existing app database before source-filtered IPC queries', async () => {
|
||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);
|
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user