feat(activity): add contribution ledger

This commit is contained in:
tommy0103
2026-07-12 15:15:48 +08:00
parent 644942202e
commit 48f1a4c225
10 changed files with 686 additions and 335 deletions
+34
View File
@@ -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>
+101 -330
View File
@@ -1,8 +1,9 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { state } from '../store.js';
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
import ActivityLedger from '../components/ActivityLedger.vue';
defineOptions({ name: 'Activity' });
@@ -14,7 +15,6 @@ const loading = ref(true);
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
const selectedDayKey = ref(null);
const loadedMonths = ref(0);
const monthBlocks = ref([]);
// Tooltip
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 };
}
const expandedNoise = reactive({});
function toggleNoise(key) {
expandedNoise[key] = !expandedNoise[key];
}
function localDateStr(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -250,7 +245,9 @@ const daySessions = computed(() => {
return {
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'),
newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'),
@@ -269,13 +266,17 @@ const daySessionsSplit = computed(() => {
});
const monthBlocksSplit = computed(() =>
monthBlocks.value.map((b, bi) => ({
...b,
bi,
newWorkspaces: splitNoise(b.newWorkspaces),
newSessions: splitNoise(b.newSessions),
continued: splitNoise(b.continued),
}))
Array.from({ length: loadedMonths.value }, (_, offset) => {
const today = new Date();
const targetDate = new Date(today.getFullYear(), today.getMonth() - offset, 1);
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
return {
...block,
newWorkspaces: splitNoise(block.newWorkspaces),
newSessions: splitNoise(block.newSessions),
continued: splitNoise(block.continued),
};
})
);
// --- Methods ---
@@ -349,6 +350,7 @@ function buildMonthBlock(year, month) {
return {
header: `${MONTHS_FULL[month]} ${year}`,
sessionTotal: classified.length,
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'),
@@ -357,26 +359,12 @@ function buildMonthBlock(year, month) {
}
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++;
}
function projectLabel(project) {
return formatProjectLabel(project);
}
function newSessionProjectCount(sessions) {
const projects = new Set(sessions.map(s => s.project || '(none)'));
return projects.size;
}
// --- Lifecycle ---
onMounted(async () => {
async function loadUsageStats() {
try {
const data = await window.obelisk.getUsageStats();
const data = await window.obelisk.getUsageStats({ source: 'all' });
usageData.daily = data.daily || [];
usageData.totalTokens = data.totalTokens || 0;
usageData.peakDay = data.peakDay || null;
@@ -384,9 +372,21 @@ onMounted(async () => {
} catch (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;
showNextMonth();
});
onUnmounted(() => stopUsageUpdates());
</script>
<template>
@@ -544,230 +544,42 @@ onMounted(async () => {
<div v-else class="empty">No data</div>
</div>
<!-- Day sessions panel (from heatmap click) -->
<div class="day-sessions" v-if="daySessionsSplit">
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> no sessions</template></div>
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
<!-- New workspaces -->
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</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">&#8627;</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>
<!-- Session activity ledger -->
<section class="session-activity" v-if="daySessionsSplit">
<div class="activity-month-heading">
<h2>{{ daySessionsSplit.header }}</h2>
<span class="activity-month-rule"></span>
<span class="activity-month-count">{{ daySessionsSplit.sessionTotal }} session{{ daySessionsSplit.sessionTotal === 1 ? '' : 's' }}</span>
</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 -->
<div class="day-sessions" v-if="!selectedDayKey">
<template v-for="(block, bi) in monthBlocksSplit" :key="bi">
<div class="day-sessions-header">{{ block.header }}</div>
<div class="day-activity-timeline" v-if="!block.isEmpty">
<div class="activity-group" v-if="block.newWorkspaces.total">
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</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>
<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="block.newWorkspaces.noise.length">
<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 class="activity-group" v-if="block.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<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 class="activity-group-items">
<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">&#8627;</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>
<section class="session-activity" v-else>
<section
v-for="block in monthBlocksSplit"
:key="block.header"
class="activity-month-block"
>
<div class="activity-month-heading">
<h2>{{ block.header }}</h2>
<span class="activity-month-rule"></span>
<span class="activity-month-count">{{ block.sessionTotal }} session{{ block.sessionTotal === 1 ? '' : 's' }}</span>
</div>
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
</template>
<ActivityLedger
v-if="!block.isEmpty"
:block="block"
@open-session="goToSession"
/>
<div v-else class="activity-empty">No sessions this month.</div>
</section>
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
</div>
</section>
<!-- Tooltip -->
<div
@@ -861,96 +673,55 @@ onMounted(async () => {
}
.chart-tooltip.show { opacity: 1; }
/* Day sessions panel */
.day-sessions { margin-top: 24px; }
.day-sessions-header {
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; }
/* Session activity ledger */
.session-activity { margin-top: 34px; }
.activity-month-block { margin-bottom: 54px; }
.day-activity-timeline {
display: flex; flex-direction: column; gap: 20px;
padding-left: 16px; border-left: 2px solid var(--hairline);
.activity-month-heading {
display: grid;
grid-template-columns: max-content minmax(48px, 1fr) max-content;
align-items: center;
gap: 18px;
margin-bottom: 26px;
}
.activity-group { position: relative; }
.activity-group-header {
display: flex; align-items: center; gap: 10px;
margin-bottom: 8px; font-size: 14px; color: var(--fg);
font-weight: 500;
.activity-month-heading h2 {
margin: 0;
color: var(--fg);
font-size: 14px;
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-group.continued .activity-group-title { color: var(--muted); }
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
.activity-item {
display: flex; align-items: center; justify-content: space-between;
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-month-rule { height: 1px; background: var(--hairline); }
.activity-month-count {
color: var(--muted-2);
font: 10px/1 var(--font-mono);
letter-spacing: .04em;
white-space: nowrap;
}
.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-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
.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;
.activity-empty {
padding: 4px 0 30px 74px;
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 {
display: block; width: 100%; margin-top: 20px;
padding: 8px; border-radius: 4px;
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
cursor: pointer; transition: all 0.1s; text-align: center;
display: block;
width: fit-content;
margin: -18px auto 16px;
padding: 8px 12px;
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; }
</style>