Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ It works on local files directly and does not require cloud hosting.

**Session Management**
- Unified Codex + Claude session list
- Local session pinning with persistent pinned state and pinned-first ordering
- Keyword/source/cwd filters
- Markdown export
- Session-level and message-level delete (supports batch)
Expand Down Expand Up @@ -164,6 +165,7 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2"

### Sessions Mode
- Unified Codex + Claude sessions
- Local pin/unpin with persistent storage and pinned-first ordering
- Search, filter, export, delete, batch cleanup

## MCP
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:

**会话管理**
- 同页查看 Codex 与 Claude 会话
- 支持本地会话置顶,置顶状态持久化保存并优先排序显示
- 关键词搜索、来源筛选、cwd 路径筛选
- 会话导出 Markdown
- 会话与消息级删除(支持批量)
Expand Down Expand Up @@ -164,6 +165,7 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2

### 会话模式
- Codex + Claude 会话统一列表
- 支持本地会话置顶、持久化保存与置顶优先排序
- 搜索、筛选、导出、删除、批量清理

## MCP
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/config-tabs-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ test('config template keeps expected config tabs in top and side navigation', ()
assert.match(html, /data-config-mode=\"codex\"/);
assert.match(html, /isMainTabNavActive\('settings'\)/);
assert.match(html, /isConfigModeNavActive\('codex'\)/);
assert.doesNotMatch(html, /:aria-pressed=/);
assert.match(html, /:aria-pressed="isSessionPinned\(session\)"/);
assert.match(html, /class="session-item-copy session-item-pin"/);
assert.match(html, /class="pin-icon"/);
assert.match(html, /:aria-selected="mainTab === 'sessions'"/);
assert.match(html, /:aria-selected="mainTab === 'config' && configMode === 'codex'"/);
assert.match(html, /v-memo="\[activeSessionExportKey === getSessionExportKey\(session\)/);
Expand Down
162 changes: 159 additions & 3 deletions tests/unit/session-trash-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1623,7 +1623,9 @@ test('deleteSession increments trash badge count when only total count has been
sessionTrashCountRequestToken: 0,
sessionTrashListRequestToken: 0,
isDeleteAvailable: () => true,
getSessionExportKey: () => 'codex:session-1',
getSessionExportKey(session) {
return `${session.source}:${session.sessionId}:${session.filePath}`;
},
invalidateSessionTrashRequests() {
this.sessionTrashCountRequestToken += 1;
this.sessionTrashListRequestToken += 1;
Expand All @@ -1643,6 +1645,10 @@ test('deleteSession increments trash badge count when only total count has been
buildSessionTrashItemFromSession() {
throw new Error('list hydration path should not run when only count is loaded');
},
removeSessionPinCalls: [],
removeSessionPin(session) {
this.removeSessionPinCalls.push(session);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async removeSessionFromCurrentList() {
removed = true;
},
Expand All @@ -1658,8 +1664,13 @@ test('deleteSession increments trash badge count when only total count has been
});

assert.strictEqual(context.sessionTrashTotalCount, 6);
assert.strictEqual(context.sessionDeleting['codex:session-1'], false);
assert.strictEqual(context.sessionDeleting['codex:session-1:/tmp/session-1.jsonl'], false);
assert.strictEqual(removed, true);
assert.deepStrictEqual(context.removeSessionPinCalls, [{
source: 'codex',
sessionId: 'session-1',
filePath: '/tmp/session-1.jsonl'
}]);
assert.strictEqual(requestedAction, 'trash-session');
assert.deepStrictEqual(messages, [{ message: '已移入回收站', tone: 'success' }]);
});
Expand All @@ -1684,7 +1695,9 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp
sessionTrashCountRequestToken: 0,
sessionTrashListRequestToken: 0,
isDeleteAvailable: () => true,
getSessionExportKey: () => 'codex:session-1',
getSessionExportKey(session) {
return `${session.source}:${session.sessionId}:${session.filePath}`;
},
invalidateSessionTrashRequests() {
this.sessionTrashCountRequestToken += 1;
this.sessionTrashListRequestToken += 1;
Expand All @@ -1704,6 +1717,10 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp
buildSessionTrashItemFromSession() {
throw new Error('loaded-list branch should not run in count-only test');
},
removeSessionPinCalls: [],
removeSessionPin(session) {
this.removeSessionPinCalls.push(session);
},
async removeSessionFromCurrentList() {},
showMessage() {}
};
Expand All @@ -1715,6 +1732,11 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp
});

assert.strictEqual(context.sessionTrashTotalCount, 500);
assert.deepStrictEqual(context.removeSessionPinCalls, [{
source: 'codex',
sessionId: 'session-1',
filePath: '/tmp/session-1.jsonl'
}]);
});

test('prependSessionTrashItem prefers authoritative trash totalCount when provided', () => {
Expand Down Expand Up @@ -1748,6 +1770,140 @@ test('prependSessionTrashItem prefers authoritative trash totalCount when provid
assert.strictEqual(context.sessionTrashTotalCount, 500);
});

test('pruneSessionPinnedMap removes stale pinned session keys after an unfiltered full session load', () => {
const pruneSessionPinnedMap = instantiateFunction(
extractMethodAsFunction(appSource, 'pruneSessionPinnedMap'),
'pruneSessionPinnedMap',
{ isSessionQueryEnabled: () => true }
);

let persisted = 0;
const context = {
sessionPinnedMap: {
'codex:keep': 111,
'codex:stale': 222
},
sessionsList: [
{ key: 'codex:keep' }
],
sessionFilterSource: 'all',
sessionPathFilter: '',
sessionQuery: '',
sessionRoleFilter: 'all',
sessionTimePreset: 'all',
getSessionExportKey(session) {
return session && session.key;
},
persistSessionPinnedMap() {
persisted += 1;
},
shouldPruneSessionPinnedMap: instantiateFunction(
extractMethodAsFunction(appSource, 'shouldPruneSessionPinnedMap'),
'shouldPruneSessionPinnedMap',
{ isSessionQueryEnabled: () => true }
)
};

pruneSessionPinnedMap.call(context);

assert.deepStrictEqual(context.sessionPinnedMap, { 'codex:keep': 111 });
assert.strictEqual(persisted, 1);
});

test('pruneSessionPinnedMap skips pruning when the visible session list is filtered', () => {
const pruneSessionPinnedMap = instantiateFunction(
extractMethodAsFunction(appSource, 'pruneSessionPinnedMap'),
'pruneSessionPinnedMap',
{ isSessionQueryEnabled: () => true }
);

let persisted = 0;
const context = {
sessionPinnedMap: {
'codex:keep': 111,
'codex:hidden': 222
},
sessionsList: [
{ key: 'codex:keep' }
],
sessionFilterSource: 'codex',
sessionPathFilter: '',
sessionQuery: '',
sessionRoleFilter: 'all',
sessionTimePreset: 'all',
getSessionExportKey(session) {
return session && session.key;
},
persistSessionPinnedMap() {
persisted += 1;
},
shouldPruneSessionPinnedMap: instantiateFunction(
extractMethodAsFunction(appSource, 'shouldPruneSessionPinnedMap'),
'shouldPruneSessionPinnedMap',
{ isSessionQueryEnabled: () => true }
)
};

pruneSessionPinnedMap.call(context);

assert.deepStrictEqual(context.sessionPinnedMap, {
'codex:keep': 111,
'codex:hidden': 222
});
assert.strictEqual(persisted, 0);
});

test('restoreSessionPinnedMap normalizes cache without pruning stale entries before sessions load', () => {
const restoreSessionPinnedMap = instantiateFunction(
extractMethodAsFunction(appSource, 'restoreSessionPinnedMap'),
'restoreSessionPinnedMap',
{
localStorage: {
getItem(key) {
assert.strictEqual(key, 'codexmateSessionPinnedMap');
return JSON.stringify({
'codex:keep': 123,
'codex:stale': 456,
'codex:bad': -1
});
},
removeItem() {
throw new Error('removeItem should not be called for valid cached JSON');
}
}
}
);

const context = {
sessionPinnedMap: {},
sessionsList: [{ key: 'codex:keep' }],
normalizeSessionPinnedMap(raw) {
const next = {};
for (const [key, value] of Object.entries(raw || {})) {
const numeric = Number(value);
if (key && Number.isFinite(numeric) && numeric > 0) {
next[key] = Math.floor(numeric);
}
}
return next;
},
getSessionExportKey(session) {
return session && session.key;
},
persistSessionPinnedMap() {
this.persistedSnapshot = { ...this.sessionPinnedMap };
}
};

restoreSessionPinnedMap.call(context);

assert.deepStrictEqual(context.sessionPinnedMap, {
'codex:keep': 123,
'codex:stale': 456
});
assert.strictEqual(context.persistedSnapshot, undefined);
});

test('loadSessionTrash replays the latest queued refresh after an in-flight request is invalidated', async () => {
const loadSessionTrashSource = extractMethodAsFunction(appSource, 'loadSessionTrash');
const pendingResponses = [];
Expand Down
Loading
Loading