Skip to content

Commit 5aa45a3

Browse files
committed
feat(web-ui): refine sessions filters and usage heatmap
- Show active session filters as removable chips and surface cwd in the list\n- Add usage activity heatmap and render it in a GitHub-style weekly grid\n- Keep sessions conversion/resume flows stable and update i18n + tests
1 parent 29a9066 commit 5aa45a3

15 files changed

Lines changed: 787 additions & 20 deletions

cli.js

Lines changed: 137 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4515,7 +4515,10 @@ function listCodexSessions(limit, options = {}) {
45154515
titleReadBytes
45164516
});
45174517
if (summary) {
4518-
sessions.push(summary);
4518+
sessions.push({
4519+
...summary,
4520+
derived: isDerivedSessionFile(filePath)
4521+
});
45194522
}
45204523

45214524
if (sessions.length >= targetCount) {
@@ -4697,6 +4700,7 @@ function listClaudeSessions(limit, options = {}) {
46974700
models,
46984701
__messageCountExact: quickRecords.length > 0 && isSessionSummaryMessageCountExact(fileStat, summaryReadBytes),
46994702
filePath,
4703+
derived: isDerivedSessionFile(filePath),
47004704
keywords,
47014705
capabilities
47024706
});
@@ -4723,7 +4727,10 @@ function listClaudeSessions(limit, options = {}) {
47234727
titleReadBytes
47244728
});
47254729
if (summary) {
4726-
sessions.push(summary);
4730+
sessions.push({
4731+
...summary,
4732+
derived: isDerivedSessionFile(filePath)
4733+
});
47274734
}
47284735

47294736
if (sessions.length >= targetCount) {
@@ -4745,7 +4752,10 @@ function listClaudeSessions(limit, options = {}) {
47454752
titleReadBytes
47464753
});
47474754
if (summary) {
4748-
sessions.push(summary);
4755+
sessions.push({
4756+
...summary,
4757+
derived: isDerivedSessionFile(filePath)
4758+
});
47494759
}
47504760
seen.add(filePath);
47514761
}
@@ -5210,6 +5220,65 @@ function findClaudeSessionIndexPath(sessionFilePath) {
52105220
return '';
52115221
}
52125222

5223+
function resolveClaudeProjectDirForCwd(cwd) {
5224+
const projectsDir = getClaudeProjectsDir();
5225+
const raw = typeof cwd === 'string' ? cwd.trim() : '';
5226+
if (!projectsDir || !raw) {
5227+
return '';
5228+
}
5229+
const ignoreCase = process.platform === 'win32';
5230+
const resolvedCwd = path.resolve(expandHomePath(raw));
5231+
let entries = [];
5232+
try {
5233+
entries = fs.readdirSync(projectsDir, { withFileTypes: true });
5234+
} catch (_) {
5235+
entries = [];
5236+
}
5237+
for (const entry of entries) {
5238+
if (!entry || !entry.isDirectory()) continue;
5239+
const projectDir = path.join(projectsDir, entry.name);
5240+
const indexPath = path.join(projectDir, 'sessions-index.json');
5241+
if (!fs.existsSync(indexPath)) continue;
5242+
const index = readJsonFile(indexPath, null);
5243+
const originalPathRaw = index && typeof index.originalPath === 'string' ? index.originalPath.trim() : '';
5244+
if (!originalPathRaw) continue;
5245+
const resolvedOriginal = path.resolve(expandHomePath(originalPathRaw));
5246+
if (normalizePathForCompare(resolvedOriginal, { ignoreCase }) === normalizePathForCompare(resolvedCwd, { ignoreCase })) {
5247+
return projectDir;
5248+
}
5249+
}
5250+
const hash = crypto.createHash('sha1').update(resolvedCwd).digest('hex').slice(0, 12);
5251+
return path.join(projectsDir, `codexmate-${hash}`);
5252+
}
5253+
5254+
function ensureClaudeSessionsIndex(indexPath, originalPath) {
5255+
if (!indexPath) return;
5256+
const resolvedOriginal = typeof originalPath === 'string' && originalPath.trim()
5257+
? path.resolve(expandHomePath(originalPath.trim()))
5258+
: '';
5259+
const existing = readJsonFile(indexPath, null);
5260+
const index = existing && typeof existing === 'object' && !Array.isArray(existing)
5261+
? { ...existing }
5262+
: { entries: [] };
5263+
if (!Array.isArray(index.entries)) {
5264+
index.entries = [];
5265+
}
5266+
if (!index.originalPath && resolvedOriginal) {
5267+
index.originalPath = resolvedOriginal;
5268+
}
5269+
if (!fs.existsSync(indexPath)) {
5270+
if (!index.originalPath) {
5271+
index.originalPath = resolvedOriginal || path.dirname(indexPath);
5272+
}
5273+
writeJsonAtomic(indexPath, index);
5274+
return;
5275+
}
5276+
if (existing && typeof existing === 'object' && !Array.isArray(existing) && existing.originalPath === index.originalPath) {
5277+
return;
5278+
}
5279+
writeJsonAtomic(indexPath, index);
5280+
}
5281+
52135282
const {
52145283
findAvailablePort,
52155284
saveBuiltinProxySettings,
@@ -6265,6 +6334,28 @@ function buildSessionPlainText(messages) {
62656334
return lines.join('\n');
62666335
}
62676336

6337+
function getDerivedSessionMetaPath(filePath) {
6338+
if (!filePath) return '';
6339+
const base = filePath.toLowerCase().endsWith('.jsonl')
6340+
? filePath.slice(0, -5)
6341+
: filePath;
6342+
return `${base}.meta.json`;
6343+
}
6344+
6345+
function isDerivedSessionFile(filePath) {
6346+
const metaPath = getDerivedSessionMetaPath(filePath);
6347+
if (!metaPath) return false;
6348+
try {
6349+
if (fs.existsSync(metaPath)) {
6350+
return true;
6351+
}
6352+
} catch (_) {
6353+
return false;
6354+
}
6355+
const base = path.basename(filePath || '', path.extname(filePath || ''));
6356+
return /-\d{8}-\d{6}-[0-9a-f]{6}$/i.test(base);
6357+
}
6358+
62686359
function resolveStateMaxMessages(state) {
62696360
if (!state || typeof state !== 'object') {
62706361
return MAX_EXPORT_MESSAGES;
@@ -6610,6 +6701,20 @@ async function readSessionDetail(params = {}) {
66106701
sessionId,
66116702
cwd: extracted.cwd || '',
66126703
updatedAt: extracted.updatedAt || '',
6704+
derived: (() => {
6705+
try {
6706+
const metaPath = filePath.toLowerCase().endsWith('.jsonl')
6707+
? `${filePath.slice(0, -5)}.meta.json`
6708+
: `${filePath}.meta.json`;
6709+
if (fs.existsSync(metaPath)) {
6710+
return true;
6711+
}
6712+
} catch (_) {
6713+
return false;
6714+
}
6715+
const base = path.basename(filePath || '', path.extname(filePath || ''));
6716+
return /-\d{8}-\d{6}-[0-9a-f]{6}$/i.test(base);
6717+
})(),
66136718
totalMessages: hasExactTotalMessages ? extracted.totalMessages : null,
66146719
clipped: typeof extracted.clipped === 'boolean'
66156720
? extracted.clipped
@@ -6637,6 +6742,15 @@ async function readSessionPlain(params = {}) {
66376742
return { error: 'Session file not found' };
66386743
}
66396744

6745+
const rawMaxMessages = params.maxMessages;
6746+
const maxMessages = rawMaxMessages === Infinity || rawMaxMessages === 'all'
6747+
? Infinity
6748+
: (
6749+
Number.isFinite(Number(rawMaxMessages))
6750+
? Math.max(1, Math.floor(Number(rawMaxMessages)))
6751+
: 50
6752+
);
6753+
66406754
let extracted;
66416755
if (source === 'gemini') {
66426756
let json;
@@ -6657,15 +6771,19 @@ async function readSessionPlain(params = {}) {
66576771
const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
66586772
if (!text && role !== 'system') continue;
66596773
messages.push({ role, text });
6774+
if (maxMessages !== Infinity && messages.length >= maxMessages) {
6775+
break;
6776+
}
66606777
}
66616778
extracted = {
66626779
sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'),
66636780
cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '',
6664-
messages
6781+
messages,
6782+
truncated: maxMessages !== Infinity && rawMessages.length > messages.length
66656783
};
66666784
} else {
66676785
try {
6668-
extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity });
6786+
extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
66696787
} catch (e) {
66706788
extracted = null;
66716789
}
@@ -6679,7 +6797,7 @@ async function readSessionPlain(params = {}) {
66796797
if (fallbackRecords.length === 0) {
66806798
return { error: 'Session file is empty' };
66816799
}
6682-
extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity });
6800+
extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
66836801
}
66846802
}
66856803

@@ -6698,7 +6816,8 @@ async function readSessionPlain(params = {}) {
66986816
sessionId,
66996817
title: sessionId,
67006818
filePath,
6701-
text
6819+
text,
6820+
clipped: maxMessages !== Infinity && !!(extracted && extracted.truncated)
67026821
};
67036822
}
67046823

@@ -6838,13 +6957,14 @@ async function convertSessionToDerived(params = {}) {
68386957
const outputDir = target === 'codex'
68396958
? getCodexSessionsDir()
68406959
: (target === 'claude'
6841-
? path.join(getClaudeProjectsDir(), 'codexmate-derived')
6960+
? (resolveClaudeProjectDirForCwd(extracted.cwd || '') || path.join(getClaudeProjectsDir(), 'codexmate-derived'))
68426961
: buildDerivedSessionOutputDir(target, source, sourceKey));
68436962
ensureDir(outputDir);
68446963
const outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
68456964
const metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);
68466965

68476966
const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
6967+
const resolvedCwd = cwd ? path.resolve(expandHomePath(cwd)) : '';
68486968
const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
68496969
const now = Date.now();
68506970
const baseTime = new Date(now).toISOString();
@@ -6866,6 +6986,7 @@ async function convertSessionToDerived(params = {}) {
68666986
}));
68676987
}
68686988
} else {
6989+
const claudeIndexPath = target === 'claude' ? path.join(outputDir, 'sessions-index.json') : '';
68696990
for (let i = 0; i < messages.length; i += 1) {
68706991
const message = messages[i];
68716992
if (!message) continue;
@@ -6881,6 +7002,9 @@ async function convertSessionToDerived(params = {}) {
68817002
message: { content: text }
68827003
}));
68837004
}
7005+
if (claudeIndexPath) {
7006+
ensureClaudeSessionsIndex(claudeIndexPath, resolvedCwd);
7007+
}
68847008
}
68857009

68867010
fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf-8');
@@ -6909,6 +7033,7 @@ async function convertSessionToDerived(params = {}) {
69097033
: parseClaudeSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES });
69107034
if (target === 'claude' && summary) {
69117035
const indexPath = path.join(outputDir, 'sessions-index.json');
7036+
ensureClaudeSessionsIndex(indexPath, resolvedCwd);
69127037
upsertClaudeSessionIndexEntry(indexPath, outputPath, {
69137038
source: 'claude',
69147039
trashId: summary.sessionId,
@@ -6921,7 +7046,8 @@ async function convertSessionToDerived(params = {}) {
69217046
messageCount: summary.messageCount,
69227047
provider: summary.provider,
69237048
keywords: summary.keywords,
6924-
capabilities: summary.capabilities
7049+
capabilities: summary.capabilities,
7050+
claudeIndexEntry: resolvedCwd ? { projectPath: resolvedCwd } : null
69257051
});
69267052
}
69277053
const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
@@ -6932,7 +7058,7 @@ async function convertSessionToDerived(params = {}) {
69327058
target,
69337059
truncated: !!extracted.truncated,
69347060
maxMessages: maxMessagesLabel,
6935-
session: summary || {
7061+
session: summary ? { ...summary, derived: true } : {
69367062
source: target,
69377063
sourceLabel: target === 'codex' ? 'Codex' : 'Claude Code',
69387064
sessionId: derivedSessionId,
@@ -6949,6 +7075,7 @@ async function convertSessionToDerived(params = {}) {
69497075
reasoningOutputTokens: 0,
69507076
__messageCountExact: true,
69517077
filePath: outputPath,
7078+
derived: true,
69527079
keywords: [],
69537080
capabilities: {}
69547081
}

tests/unit/session-usage.test.mjs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
55
const __filename = fileURLToPath(import.meta.url);
66
const __dirname = path.dirname(__filename);
77
const logic = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs')));
8-
const { buildUsageChartGroups } = logic;
8+
const { buildUsageChartGroups, buildUsageHeatmap } = logic;
99

1010
test('buildUsageChartGroups aggregates codex and claude sessions into day buckets', () => {
1111
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
@@ -91,3 +91,19 @@ test('buildUsageChartGroups supports all range and keeps every valid session in
9191
assert.strictEqual(result.buckets[0].key, '2026-03-01');
9292
assert.strictEqual(result.buckets[result.buckets.length - 1].key, '2026-04-06');
9393
});
94+
95+
test('buildUsageHeatmap aligns to monday and aggregates sessions per day', () => {
96+
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
97+
const result = buildUsageHeatmap([
98+
{ source: 'codex', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 5, totalTokens: 120 },
99+
{ source: 'claude', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 7, totalTokens: 230 },
100+
{ source: 'codex', updatedAt: '2026-04-05T09:00:00.000Z', messageCount: 3, totalTokens: 90 }
101+
], { range: '7d', now });
102+
103+
assert.strictEqual(result.range, '7d');
104+
assert.ok(Array.isArray(result.weeks));
105+
assert.ok(result.weeks.length >= 1);
106+
assert.ok(result.maxSessionCount >= 2);
107+
const hasDay = result.weeks.some((week) => Array.isArray(week.days) && week.days.some((cell) => cell && cell.dateKey === '2026-04-06' && cell.sessionCount === 2));
108+
assert.ok(hasDay);
109+
});

tests/unit/web-ui-behavior-parity.test.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,11 @@ test('captured bundled app skeleton only exposes expected data key drift versus
487487
'openClaudeMdEditor'
488488
];
489489
allowedExtraCurrentMethodKeys.push(
490+
'hasActiveSessionFilters',
491+
'getSessionFilterChips',
492+
'clearSessionFilterChip',
493+
'isDerivedSession',
494+
'isDerivedSessionId',
490495
'resetConfigTemplateDiffState',
491496
'onConfigTemplateContentInput',
492497
'buildConfigTemplateDiffFingerprint',
@@ -595,6 +600,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
595600
'promptComposerPickerList',
596601
'promptComposerMissingVars',
597602
'sessionUsageDaily',
603+
'sessionUsageHeatmap',
598604
'sessionUsageDailyTableRows',
599605
'usageCurrentSessionStats',
600606
'taskOrchestrationSelectedRun',

0 commit comments

Comments
 (0)