-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
{{ block.header }}
+
+ {{ block.sessionTotal }} session{{ block.sessionTotal === 1 ? '' : 's' }}
- No sessions this month.
-
+
+ No sessions this month.
+
-
+
{
}
.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; }
diff --git a/app/src/shared/ipc-types.ts b/app/src/shared/ipc-types.ts
new file mode 100644
index 0000000..19c0a67
--- /dev/null
+++ b/app/src/shared/ipc-types.ts
@@ -0,0 +1,5 @@
+export interface SourceQueryOptions {
+ source?: string;
+}
+
+export type UsageStatsOptions = SourceQueryOptions;
diff --git a/tests/app-activity-ledger.test.mjs b/tests/app-activity-ledger.test.mjs
new file mode 100644
index 0000000..5ce87df
--- /dev/null
+++ b/tests/app-activity-ledger.test.mjs
@@ -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');
+});
diff --git a/tests/app-activity-usage.test.mjs b/tests/app-activity-usage.test.mjs
new file mode 100644
index 0000000..8e90117
--- /dev/null
+++ b/tests/app-activity-usage.test.mjs
@@ -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();
+ }
+});
diff --git a/tests/app-main-settings.test.mjs b/tests/app-main-settings.test.mjs
index 8fe4370..d7cc5f0 100644
--- a/tests/app-main-settings.test.mjs
+++ b/tests/app-main-settings.test.mjs
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
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 { 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, {});
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'/);
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 () => {
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);