Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Codex Mate is a local-first CLI + Web UI for unified management of:
- Claude Code profiles (writes to `~/.claude/settings.json`)
- OpenClaw JSON5 profiles and workspace `AGENTS.md`
- Local skills market for Codex / Claude Code (target switching, local skills management, cross-app import, ZIP distribution)
- Local Codex/Claude sessions (list/filter/export/delete)
- Local Codex/Claude sessions (list/filter/export/delete) with Usage analytics overview

It works on local files directly and does not require cloud hosting. The skills market is also local-first: it operates on local directories and does not depend on a remote marketplace.

Expand All @@ -34,8 +34,9 @@ It works on local files directly and does not require cloud hosting. The skills
| --- | --- | --- |
| Multi-tool management | Codex + Claude Code + OpenClaw in one entry | Different files and folders per tool |
| Operation mode | CLI + local Web UI | Manual TOML/JSON/JSON5 edits |
| Session handling | Browse/export/batch cleanup | Manual file location and processing |
| Session handling | Browse/filter/Usage analytics/export/batch cleanup | Manual file location and processing |
| Skills reuse | Local skills market + cross-app import + ZIP distribution | Manual folder copy and reconciliation |
| Operational visibility | Unified view of config, sessions, and Usage summaries | Depends on manual file inspection and scattered commands |
| Rollback readiness | Backup before first takeover | Easy to overwrite by mistake |
| Automation integration | MCP stdio (read-only by default) | Requires custom scripting |

Expand All @@ -51,6 +52,7 @@ It works on local files directly and does not require cloud hosting. The skills
- Unified Codex + Claude session list
- Local session pinning with persistent pinned state and pinned-first ordering
- Keyword/source/cwd filters
- Usage subview with 7d / 30d session trends, message trends, source share, and top paths
- Markdown export
- Session-level and message-level delete (supports batch)

Expand Down Expand Up @@ -82,7 +84,7 @@ flowchart TB
API["Local HTTP API"]
MCPS["MCP stdio Server"]
PROXY["Built-in Proxy"]
SERVICES["Config / Sessions / Skills Market / Workflow"]
SERVICES["Config / Sessions & Usage / Skills Market / Workflow"]
CORE["File IO / Network / Diff / Session Utils"]
end

Expand All @@ -91,7 +93,7 @@ flowchart TB
CLAUDE["~/.claude/settings.json"]
OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"]
SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"]
STATE["sessions / trash / workflow runs / skill exports"]
STATE["sessions / usage aggregates / trash / workflow runs / skill exports"]
end

CLI --> ENTRY
Expand Down Expand Up @@ -195,8 +197,10 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2"

### Sessions Mode
- Unified Codex + Claude sessions
- Browser / Usage subview switching
- Local pin/unpin with persistent storage and pinned-first ordering
- Search, filter, export, delete, batch cleanup
- Usage view includes 7d / 30d session trends, message trends, source share, and top paths

### Skills Market Tab
- Switch the skills install target between `Codex` and `Claude Code`
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
- Claude Code 配置方案(写入 `~/.claude/settings.json`)
- OpenClaw JSON5 配置与 Workspace `AGENTS.md`
- Codex / Claude Code Skills 市场(安装目标切换、本地 skills 管理、跨应用导入、ZIP 分发)
- Codex / Claude 本地会话浏览、筛选、导出、删除
- Codex / Claude 本地会话浏览、筛选、导出、删除与 Usage 统计概览

项目不依赖云端托管,配置写入你的本地文件,便于审计和回滚。Skills 市场同样坚持本地优先,只操作本地目录,不依赖远程在线市场。

Expand All @@ -34,8 +34,9 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
| --- | --- | --- |
| 多工具管理 | Codex + Claude Code + OpenClaw 统一入口 | 多文件、多目录分散修改 |
| 使用方式 | CLI + 本地 Web UI | 纯手改 TOML / JSON / JSON5 |
| 会话处理 | 支持浏览、导出、批量清理 | 需要手动定位和处理文件 |
| 会话处理 | 支持浏览、筛选、Usage 统计、导出、批量清理 | 需要手动定位和处理文件 |
| Skills 复用 | 本地 Skills 市场 + 跨应用导入 + ZIP 分发 | 目录手动复制,容易遗漏 |
| 使用可见性 | 统一查看配置、会话与 Usage 概览 | 依赖手工翻文件和零散命令 |
| 可回滚性 | 首次接管前自动备份 | 易误覆盖、回滚成本高 |
| 自动化接入 | 提供 MCP stdio(默认只读) | 需自行封装脚本 |

Expand All @@ -51,6 +52,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
- 同页查看 Codex 与 Claude 会话
- 支持本地会话置顶,置顶状态持久化保存并优先排序显示
- 关键词搜索、来源筛选、cwd 路径筛选
- Usage 子页:近 7 天 / 近 30 天会话趋势、消息趋势、来源占比、高频路径
- 会话导出 Markdown
- 会话与消息级删除(支持批量)

Expand Down Expand Up @@ -78,7 +80,7 @@ flowchart TB
ENTRY["cli.js Entry"]
API["Local HTTP API"]
MCPS["MCP stdio Server"]
SERVICES["Config / Sessions / Skills Market / Workflow"]
SERVICES["Config / Sessions & Usage / Skills Market / Workflow"]
CORE["File IO / Network / Diff / Session Utils"]
end

Expand All @@ -87,7 +89,7 @@ flowchart TB
CLAUDE["~/.claude/settings.json"]
OPENCLAW["~/.openclaw/*.json5 + ~/.openclaw/openclaw.json + workspace/AGENTS.md"]
SKILLS["~/.codex/skills / ~/.claude/skills / ~/.agents/skills"]
STATE["sessions / trash / workflow runs / skill exports"]
STATE["sessions / usage aggregates / trash / workflow runs / skill exports"]
end

CLI --> ENTRY
Expand Down Expand Up @@ -187,8 +189,10 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2

### 会话模式
- Codex + Claude 会话统一列表
- Browser / Usage 双子视图切换
- 支持本地会话置顶、持久化保存与置顶优先排序
- 搜索、筛选、导出、删除、批量清理
- Usage 视图提供近 7 天 / 近 30 天会话趋势、消息趋势、来源占比与高频路径统计

### Skills 市场标签页
- 在 `Codex` 与 `Claude Code` 之间切换 skills 安装目标
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ test('config template keeps expected config tabs in top and side navigation', ()
/:class="\['card', \{ active: currentOpenclawConfig === name \}\]"[\s\S]*@click="applyOpenclawConfig\(name\)"[\s\S]*@keydown\.enter\.self\.prevent="applyOpenclawConfig\(name\)"[\s\S]*@keydown\.space\.self\.prevent="applyOpenclawConfig\(name\)"[\s\S]*tabindex="0"[\s\S]*role="button"[\s\S]*:aria-current="currentOpenclawConfig === name \? 'true' : null"/
);
assert.match(html, /class="session-item-copy session-item-pin"/);
assert.match(sessionsPanel, /class="sessions-subtabs" role="tablist" aria-label="会话视图切换"/);
assert.match(sessionsPanel, /sessionsViewMode === 'browser'/);
assert.match(sessionsPanel, /sessionsViewMode === 'usage'/);
assert.match(sessionsPanel, /sessionsUsageTimeRange === '7d'/);
assert.match(sessionsPanel, /sessionsUsageTimeRange === '30d'/);
assert.match(sessionsPanel, /sessionUsageSummaryCards/);
assert.match(sessionsPanel, /sessionUsageCharts\.buckets/);
assert.match(html, /class="pin-icon"/);
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'config' && configMode === 'codex'"/);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ await import(pathToFileURL(path.join(__dirname, 'openclaw-persist-regression.tes
await import(pathToFileURL(path.join(__dirname, 'agents-modal-guards.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-browser-timeline-regression.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'session-usage.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'agents-diff-ui.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs')));
await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs')));
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/session-usage.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'assert';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const logic = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.mjs')));
const { buildUsageChartGroups } = logic;

test('buildUsageChartGroups aggregates codex and claude sessions into day buckets', () => {
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
const result = buildUsageChartGroups([
{ source: 'codex', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 5, cwd: '/a' },
{ source: 'claude', updatedAt: '2026-04-06T09:00:00.000Z', messageCount: 7, cwd: '/a' },
{ source: 'codex', updatedAt: '2026-04-05T09:00:00.000Z', messageCount: 3, cwd: '/b' }
], { range: '7d', now });

assert.strictEqual(result.summary.totalSessions, 3);
assert.strictEqual(result.summary.totalMessages, 15);
assert.strictEqual(result.summary.codexTotal, 2);
assert.strictEqual(result.summary.claudeTotal, 1);
assert.strictEqual(result.sourceShare.find(item => item.key === 'codex').percent, 67);
assert.strictEqual(result.topPaths[0].path, '/a');
assert.strictEqual(result.topPaths[0].count, 2);
const lastBucket = result.buckets[result.buckets.length - 1];
assert.strictEqual(lastBucket.codex, 1);
assert.strictEqual(lastBucket.claude, 1);
assert.strictEqual(lastBucket.totalMessages, 12);
});

test('buildUsageChartGroups ignores invalid sessions and keeps empty buckets stable', () => {
const now = Date.UTC(2026, 3, 6, 12, 0, 0);
const result = buildUsageChartGroups([
null,
{ source: 'other', updatedAt: '2026-04-06T08:00:00.000Z', messageCount: 9 },
{ source: 'codex', updatedAt: 'bad-date', messageCount: 2 }
], { range: '7d', now });

assert.strictEqual(result.summary.totalSessions, 0);
assert.strictEqual(result.summary.totalMessages, 0);
assert.strictEqual(result.buckets.length, 7);
assert.ok(result.buckets.every((item) => item.totalSessions === 0));
});
20 changes: 17 additions & 3 deletions tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
const headDataKeys = Object.keys(headAppOptions.data()).sort();
const extraCurrentKeys = currentDataKeys.filter((key) => !headDataKeys.includes(key)).sort();
const missingCurrentKeys = headDataKeys.filter((key) => !currentDataKeys.includes(key)).sort();
const allowedExtraCurrentKeys = [];
const allowedExtraCurrentKeys = ['sessionsUsageTimeRange', 'sessionsViewMode'];
const allowedMissingCurrentKeys = [];
if (parityAgainstHead) {
const allowedExtraKeySet = new Set(allowedExtraCurrentKeys);
Expand Down Expand Up @@ -353,9 +353,23 @@ test('captured bundled app skeleton only exposes expected data key drift versus
currentMethodKeys.filter((key) => !extraCurrentMethodKeys.includes(key)).sort(),
headMethodKeys
);
const currentComputedKeys = Object.keys(currentComputed).sort();
const headComputedKeys = Object.keys(headComputed).sort();
const extraCurrentComputedKeys = currentComputedKeys.filter((key) => !headComputedKeys.includes(key)).sort();
const missingCurrentComputedKeys = headComputedKeys.filter((key) => !currentComputedKeys.includes(key)).sort();
const allowedExtraCurrentComputedKeys = ['sessionUsageCharts', 'sessionUsageSummaryCards'];
if (parityAgainstHead) {
const allowedExtraComputedKeySet = new Set(allowedExtraCurrentComputedKeys);
const unexpectedExtraCurrentComputedKeys = extraCurrentComputedKeys.filter((key) => !allowedExtraComputedKeySet.has(key));
assert.deepStrictEqual(unexpectedExtraCurrentComputedKeys, [], `unexpected extra computed keys against ${parityBaseline.ref}`);
assert.deepStrictEqual(missingCurrentComputedKeys, [], `unexpected missing computed keys against ${parityBaseline.ref}`);
} else {
assert.deepStrictEqual(extraCurrentComputedKeys, allowedExtraCurrentComputedKeys);
assert.deepStrictEqual(missingCurrentComputedKeys, []);
}
assert.deepStrictEqual(
Object.keys(currentComputed).sort(),
Object.keys(headComputed).sort()
currentComputedKeys.filter((key) => !extraCurrentComputedKeys.includes(key)).sort(),
headComputedKeys
);
assert.strictEqual(typeof currentAppOptions.mounted, typeof headAppOptions.mounted);
assert.strictEqual(typeof currentAppOptions.beforeUnmount, typeof headAppOptions.beforeUnmount);
Expand Down
2 changes: 2 additions & 0 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ document.addEventListener('DOMContentLoaded', () => {
skillsMarketLocalLoadedOnce: false,
skillsMarketImportLoadedOnce: false,
sessionPinnedMap: {},
sessionsViewMode: 'browser',
sessionsUsageTimeRange: '7d',
sessionsList: [],
sessionsLoadedOnce: false,
sessionsLoading: false,
Expand Down
88 changes: 88 additions & 0 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,94 @@ export function formatSessionTimelineTimestamp(timestamp) {
return value;
}

export function buildUsageChartGroups(sessions = [], options = {}) {
const list = Array.isArray(sessions) ? sessions : [];
const range = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d';
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
const dayMs = 24 * 60 * 60 * 1000;
const rangeDays = range === '30d' ? 30 : 7;
const buckets = [];
Comment on lines +93 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize unsupported range values to a canonical output ('7d'/'30d').

At Line [93] and Line [163], invalid inputs currently produce 7-day buckets but return the original invalid range string. This can desync metadata from actual aggregation.

💡 Proposed fix
-    const range = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d';
+    const rawRange = typeof options.range === 'string' ? options.range.trim().toLowerCase() : '7d';
+    const range = rawRange === '30d' ? '30d' : '7d';
     const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now();
     const dayMs = 24 * 60 * 60 * 1000;
-    const rangeDays = range === '30d' ? 30 : 7;
+    const rangeDays = range === '30d' ? 30 : 7;

Also applies to: 163-163

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-ui/logic.sessions.mjs` around lines 93 - 97, The code currently accepts
arbitrary strings into the variable range and later computes buckets using
rangeDays but returns the original invalid range string; normalize unsupported
values so range becomes only the canonical '7d' or '30d' before any further use.
In practice, validate and map options.range (the variable named range) to either
'7d' or '30d' (e.g., treat anything not equal to '30d' as '7d'), update derived
rangeDays accordingly, and ensure any metadata or return values that reference
range (including the place around the later usage at the location tied to line
163) use this normalized range value so buckets and metadata stay in sync.
Ensure you adjust both the initial assignment of range and all subsequent
usages/returns that relied on the original unnormalized range.

for (let i = rangeDays - 1; i >= 0; i -= 1) {
const stamp = new Date(now - (i * dayMs));
const key = `${stamp.getUTCFullYear()}-${String(stamp.getUTCMonth() + 1).padStart(2, '0')}-${String(stamp.getUTCDate()).padStart(2, '0')}`;
buckets.push({
key,
label: key.slice(5),
codex: 0,
claude: 0,
totalMessages: 0,
totalSessions: 0
});
}
const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket]));
let codexTotal = 0;
let claudeTotal = 0;
let messageTotal = 0;
const pathMap = new Map();

for (const session of list) {
if (!session || typeof session !== 'object') continue;
const source = normalizeSessionSource(session.source, '');
if (source !== 'codex' && source !== 'claude') continue;
const updatedAtMs = Date.parse(session.updatedAt || '');
if (!Number.isFinite(updatedAtMs)) continue;
const stamp = new Date(updatedAtMs);
const key = `${stamp.getUTCFullYear()}-${String(stamp.getUTCMonth() + 1).padStart(2, '0')}-${String(stamp.getUTCDate()).padStart(2, '0')}`;
const bucket = bucketMap.get(key);
if (!bucket) continue;
const messageCount = Number.isFinite(Number(session.messageCount))
? Math.max(0, Math.floor(Number(session.messageCount)))
: 0;
bucket.totalSessions += 1;
bucket.totalMessages += messageCount;
if (source === 'codex') {
bucket.codex += 1;
codexTotal += 1;
} else {
bucket.claude += 1;
claudeTotal += 1;
}
messageTotal += messageCount;
const cwd = normalizeSessionPathFilter(session.cwd);
if (cwd) {
pathMap.set(cwd, (Number(pathMap.get(cwd)) || 0) + 1);
}
}

const totalSessions = codexTotal + claudeTotal;
const sourceShare = [
{ key: 'codex', label: 'Codex', value: codexTotal },
{ key: 'claude', label: 'Claude', value: claudeTotal }
].map((item) => ({
...item,
percent: totalSessions > 0 ? Math.round((item.value / totalSessions) * 100) : 0
}));

const topPaths = [...pathMap.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'zh-Hans-CN'))
.slice(0, 5)
.map(([pathValue, count]) => ({ path: pathValue, count }));

const maxSessionBucket = buckets.reduce((max, item) => Math.max(max, item.totalSessions), 0);
const maxMessageBucket = buckets.reduce((max, item) => Math.max(max, item.totalMessages), 0);

return {
range,
buckets,
summary: {
totalSessions,
totalMessages: messageTotal,
codexTotal,
claudeTotal,
activeDays: buckets.filter((item) => item.totalSessions > 0).length
},
sourceShare,
topPaths,
maxSessionBucket,
maxMessageBucket
};
}

export function buildSessionTimelineNodes(messages = [], options = {}) {
const list = Array.isArray(messages) ? messages : [];
const getKey = typeof options.getKey === 'function'
Expand Down
16 changes: 16 additions & 0 deletions web-ui/modules/app.computed.session.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
buildSessionTimelineNodes,
buildUsageChartGroups,
isSessionQueryEnabled
} from '../logic.mjs';
import { SESSION_TRASH_PAGE_SIZE } from './app.constants.mjs';
Expand Down Expand Up @@ -100,6 +101,21 @@ export function createSessionComputed() {
}
return '当前来源暂不支持关键词检索';
},
sessionUsageCharts() {
return buildUsageChartGroups(this.sessionsList, {
range: this.sessionsUsageTimeRange
});
},
sessionUsageSummaryCards() {
const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
? this.sessionUsageCharts.summary
: { totalSessions: 0, totalMessages: 0, activeDays: 0 };
return [
{ key: 'sessions', label: '总会话数', value: summary.totalSessions || 0 },
{ key: 'messages', label: '总消息数', value: summary.totalMessages || 0 },
{ key: 'days', label: '活跃天数', value: summary.activeDays || 0 }
];
},
visibleSessionTrashItems() {
const items = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : [];
const visibleCount = Number(this.sessionTrashVisibleCount);
Expand Down
Loading
Loading