Skip to content

Commit 3f7412e

Browse files
committed
fix: resolve remaining session trash review findings
1 parent a2b7296 commit 3f7412e

10 files changed

Lines changed: 570 additions & 38 deletions

cli.js

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5703,8 +5703,10 @@ function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {
57035703
if (!index || !Array.isArray(index.entries)) {
57045704
return { removed: false, entry: null };
57055705
}
5706-
const resolvedFile = sessionFilePath ? path.resolve(sessionFilePath) : '';
5707-
const resolvedLower = resolvedFile ? resolvedFile.toLowerCase() : '';
5706+
const ignoreCase = process.platform === 'win32';
5707+
const resolvedFile = sessionFilePath
5708+
? normalizePathForCompare(sessionFilePath, { ignoreCase })
5709+
: '';
57085710
let removedEntry = null;
57095711
const filtered = index.entries.filter((entry) => {
57105712
if (!entry || typeof entry !== 'object') {
@@ -5719,8 +5721,10 @@ function removeClaudeSessionIndexEntry(indexPath, sessionFilePath, sessionId) {
57195721
}
57205722
if (entry.fullPath) {
57215723
const expanded = expandHomePath(entry.fullPath);
5722-
const entryPath = expanded ? path.resolve(expanded) : '';
5723-
if (entryPath && resolvedLower && entryPath.toLowerCase() === resolvedLower) {
5724+
const entryPath = expanded
5725+
? normalizePathForCompare(expanded, { ignoreCase })
5726+
: '';
5727+
if (entryPath && resolvedFile && entryPath === resolvedFile) {
57245728
if (!removedEntry) {
57255729
removedEntry = entry;
57265730
}
@@ -5990,6 +5994,14 @@ function buildClaudeSessionIndexEntry(entry, sessionFilePath) {
59905994
const stored = normalized && normalized.claudeIndexEntry && typeof normalized.claudeIndexEntry === 'object'
59915995
? JSON.parse(JSON.stringify(normalized.claudeIndexEntry))
59925996
: {};
5997+
const storedCapabilities = stored && stored.capabilities && typeof stored.capabilities === 'object' && !Array.isArray(stored.capabilities)
5998+
? stored.capabilities
5999+
: null;
6000+
const storedKeywords = Array.isArray(stored && stored.keywords)
6001+
? stored.keywords
6002+
: null;
6003+
const normalizedMessageCount = Number(normalized && normalized.messageCount);
6004+
const storedMessageCount = Number(stored && stored.messageCount);
59936005
let modifiedAt = '';
59946006
try {
59956007
modifiedAt = fs.statSync(sessionFilePath).mtime.toISOString();
@@ -6014,11 +6026,23 @@ function buildClaudeSessionIndexEntry(entry, sessionFilePath) {
60146026
provider: (stored && typeof stored.provider === 'string' && stored.provider.trim())
60156027
? stored.provider.trim()
60166028
: (normalized.provider || 'claude'),
6017-
capabilities: normalizeCapabilities(stored ? stored.capabilities : normalized.capabilities),
6018-
keywords: normalizeKeywords(stored ? stored.keywords : normalized.keywords),
6019-
messageCount: Number.isFinite(Number(stored && stored.messageCount))
6020-
? Math.max(0, Number(stored.messageCount))
6021-
: buildClaudeStoredIndexMessageCount(normalized.messageCount)
6029+
capabilities: normalizeCapabilities(
6030+
storedCapabilities && Object.keys(storedCapabilities).length > 0
6031+
? storedCapabilities
6032+
: normalized.capabilities
6033+
),
6034+
keywords: normalizeKeywords(
6035+
storedKeywords && storedKeywords.length > 0
6036+
? storedKeywords
6037+
: normalized.keywords
6038+
),
6039+
messageCount: Number.isFinite(normalizedMessageCount)
6040+
? buildClaudeStoredIndexMessageCount(normalizedMessageCount)
6041+
: (
6042+
Number.isFinite(storedMessageCount)
6043+
? Math.max(0, Math.floor(storedMessageCount))
6044+
: buildClaudeStoredIndexMessageCount(normalized && normalized.messageCount)
6045+
)
60226046
};
60236047
}
60246048

@@ -6031,7 +6055,8 @@ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {
60316055
? parsed
60326056
: {};
60336057
const entries = Array.isArray(index.entries) ? index.entries : [];
6034-
const resolvedFile = path.resolve(sessionFilePath).toLowerCase();
6058+
const ignoreCase = process.platform === 'win32';
6059+
const resolvedFile = normalizePathForCompare(sessionFilePath, { ignoreCase });
60356060
const normalizedEntry = normalizeSessionTrashEntry(entry);
60366061
const filtered = entries.filter((item) => {
60376062
if (!item || typeof item !== 'object') {
@@ -6043,7 +6068,9 @@ function upsertClaudeSessionIndexEntry(indexPath, sessionFilePath, entry) {
60436068
}
60446069
if (typeof item.fullPath === 'string' && item.fullPath) {
60456070
const expanded = expandHomePath(item.fullPath);
6046-
const itemPath = expanded ? path.resolve(expanded).toLowerCase() : '';
6071+
const itemPath = expanded
6072+
? normalizePathForCompare(expanded, { ignoreCase })
6073+
: '';
60476074
if (itemPath && itemPath === resolvedFile) {
60486075
return false;
60496076
}
@@ -6098,7 +6125,8 @@ async function listSessionTrashItems(params = {}) {
60986125
}
60996126
}
61006127
if (updatedEntriesById.size > 0) {
6101-
writeSessionTrashEntries(allEntries.map((entry) => updatedEntriesById.get(entry.trashId) || entry));
6128+
const latestEntries = readSessionTrashEntries({ cleanup: false });
6129+
writeSessionTrashEntries(latestEntries.map((entry) => updatedEntriesById.get(entry.trashId) || entry));
61026130
}
61036131
return {
61046132
totalCount,
@@ -6138,9 +6166,14 @@ async function restoreSessionTrashItem(params = {}) {
61386166
return { error: '原始会话路径已存在同名文件,请先手动处理冲突' };
61396167
}
61406168

6141-
const remainingEntries = entries.filter((item) => item.trashId !== trashId);
61426169
let claudeIndexPath = '';
61436170
try {
6171+
const latestEntries = readSessionTrashEntries({ cleanup: false });
6172+
const latestEntry = latestEntries.find((item) => item && item.trashId === trashId);
6173+
if (!latestEntry) {
6174+
return { error: '回收站记录不存在' };
6175+
}
6176+
const remainingEntries = latestEntries.filter((item) => item.trashId !== trashId);
61446177
moveFileSync(trashFilePath, targetFilePath);
61456178
if (hydratedEntry.source === 'claude') {
61466179
claudeIndexPath = resolveClaudeSessionRestoreIndexPath(hydratedEntry, targetFilePath);
@@ -6275,7 +6308,9 @@ async function trashSessionData(params = {}) {
62756308
claudeIndexEntry: removedClaudeIndexEntry
62766309
});
62776310
const entries = readSessionTrashEntries({ cleanup: false });
6311+
const totalCount = entries.length + 1;
62786312
writeSessionTrashEntries([entry, ...entries]);
6313+
summary.totalCount = totalCount;
62796314
} catch (e) {
62806315
if (source === 'claude' && claudeIndexPath && removedClaudeIndexEntry) {
62816316
try {
@@ -6313,6 +6348,9 @@ async function trashSessionData(params = {}) {
63136348
trashed: true,
63146349
trashId,
63156350
deletedAt,
6351+
totalCount: Number.isFinite(Number(summary && summary.totalCount))
6352+
? Math.max(0, Math.floor(Number(summary.totalCount)))
6353+
: undefined,
63166354
messageCount: Number.isFinite(Number(summary && summary.messageCount))
63176355
? Math.max(0, Math.floor(Number(summary.messageCount)))
63186356
: 0

tests/e2e/test-mcp.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,19 +198,26 @@ module.exports = async function testMcp(ctx) {
198198
);
199199

200200
const httpCodexSessions = await api('list-sessions', { source: 'codex', forceRefresh: true, limit: 100 });
201-
const httpCodexById = new Map((httpCodexSessions.sessions || []).map((item) => [item.sessionId, item]));
201+
const httpCodexByKey = new Map((httpCodexSessions.sessions || []).map((item) => [
202+
`${item.source}:${item.sessionId}:${item.filePath}`,
203+
item
204+
]));
202205
for (const item of sessionListPayload.sessions) {
203-
const httpItem = httpCodexById.get(item.sessionId);
204-
assert(httpItem, `http list-sessions missing MCP codex session ${item.sessionId}`);
206+
const key = `${item.source}:${item.sessionId}:${item.filePath}`;
207+
const httpItem = httpCodexByKey.get(key);
208+
assert(httpItem, `http list-sessions missing MCP codex session ${key}`);
205209
assert(item.messageCount === httpItem.messageCount, `mcp session.list messageCount drifted for ${item.sessionId}`);
206210
}
207211
const mcpLongSession = sessionListPayload.sessions.find((item) => item && item.sessionId === longSessionId);
208212
assert(mcpLongSession && mcpLongSession.messageCount === longMessageCount, 'mcp session.list should expose exact long-session messageCount');
209213

210214
const httpAllSessions = await api('list-sessions', { source: 'all', forceRefresh: true, limit: sessionResourcePayload.sessions.length || 120 });
211-
const httpAllByKey = new Map((httpAllSessions.sessions || []).map((item) => [`${item.source}:${item.sessionId}`, item]));
215+
const httpAllByKey = new Map((httpAllSessions.sessions || []).map((item) => [
216+
`${item.source}:${item.sessionId}:${item.filePath}`,
217+
item
218+
]));
212219
for (const item of sessionResourcePayload.sessions) {
213-
const key = `${item.source}:${item.sessionId}`;
220+
const key = `${item.source}:${item.sessionId}:${item.filePath}`;
214221
const httpItem = httpAllByKey.get(key);
215222
assert(httpItem, `http list-sessions missing MCP resource session ${key}`);
216223
assert(item.messageCount === httpItem.messageCount, `mcp sessions resource messageCount drifted for ${key}`);

tests/e2e/test-sessions.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ module.exports = async function testSessions(ctx) {
320320
});
321321
fs.writeFileSync(trashIndexPath, JSON.stringify(trashIndex, null, 2), 'utf-8');
322322

323-
const staleTrashList = await api('list-session-trash', { limit: 200 });
323+
const staleTrashList = await api('list-session-trash', { limit: 200, forceRefresh: true });
324324
assert(staleTrashList.totalCount >= staleTrashList.items.length, 'list-session-trash totalCount should be returned with stale-count repair');
325325
const correctedStaleTrashItem = staleTrashList.items.find(item => item.trashId === staleTrashId);
326326
assert(correctedStaleTrashItem, 'stale trash entry should still be listed');
@@ -352,7 +352,7 @@ module.exports = async function testSessions(ctx) {
352352
});
353353
fs.writeFileSync(trashIndexPath, JSON.stringify(mtimeOnlyIndex, null, 2), 'utf-8');
354354

355-
const mtimeOnlyTrashList = await api('list-session-trash', { limit: 200 });
355+
const mtimeOnlyTrashList = await api('list-session-trash', { limit: 200, forceRefresh: true });
356356
const mtimeOnlyTrashItem = mtimeOnlyTrashList.items.find(item => item.trashId === mtimeOnlyTrashId);
357357
assert(mtimeOnlyTrashItem, 'mtime-only trash entry should still be listed');
358358

@@ -387,7 +387,7 @@ module.exports = async function testSessions(ctx) {
387387
}
388388
fs.writeFileSync(trashIndexPath, JSON.stringify(overflowIndex, null, 2), 'utf-8');
389389

390-
const overflowTrashList = await api('list-session-trash', { limit: 200 });
390+
const overflowTrashList = await api('list-session-trash', { limit: 200, forceRefresh: true });
391391
assert(overflowTrashList.totalCount === mtimeOnlyTrashList.totalCount + overflowExtraCount, 'list-session-trash totalCount should reflect entries beyond the visible slice');
392392
assert(overflowTrashList.items.length === 200, 'list-session-trash should keep the visible slice capped by limit');
393393
assert(overflowTrashList.totalCount > overflowTrashList.items.length, 'list-session-trash totalCount should stay larger than visible items when overflowing');

tests/unit/config-tabs-ui.test.mjs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,26 @@ test('config template keeps expected config tabs in top and side navigation', ()
3434
assert.match(html, /settingsTab === 'backup'/);
3535
assert.match(html, /settingsTab === 'trash'/);
3636
assert.match(html, /sessionTrashCount/);
37+
assert.match(html, /id="settings-tab-backup"/);
38+
assert.match(html, /id="settings-tab-trash"/);
39+
assert.match(html, /role="tab"/);
40+
assert.match(html, /aria-controls="settings-panel-backup"/);
41+
assert.match(html, /aria-controls="settings-panel-trash"/);
42+
assert.match(html, /:aria-selected="settingsTab === 'backup'"/);
43+
assert.match(html, /:aria-selected="settingsTab === 'trash'"/);
44+
assert.match(html, /:tabindex="settingsTab === 'backup' \? 0 : -1"/);
45+
assert.match(html, /:tabindex="settingsTab === 'trash' \? 0 : -1"/);
46+
assert.match(html, /id="settings-panel-backup"/);
47+
assert.match(html, /id="settings-panel-trash"/);
48+
assert.match(html, /role="tabpanel"/);
3749
assert.match(html, /class="trash-item session-item session-card"/);
3850
assert.match(html, /class="trash-item-mainline"/);
3951
assert.match(html, /class="trash-item-side"/);
4052
assert.match(html, /class="trash-item-time session-item-time"/);
4153
assert.match(html, /class="trash-item-path session-item-sub session-item-wrap"/);
54+
assert.match(html, /v-for="item in visibleSessionTrashItems"/);
55+
assert.match(html, /class="session-source"/);
56+
assert.match(html, /@click="loadMoreSessionTrashItems"/);
4257
assert.match(html, //);
4358
assert.match(html, /data-main-tab=\"sessions\"/);
4459
assert.match(html, /data-config-mode=\"codex\"/);
@@ -117,24 +132,32 @@ test('web ui script defines provider mode metadata for codex only', () => {
117132
assert.match(appScript, /runLatestOnlyQueue\(/);
118133
assert.match(appScript, /providerSwitchInProgress:\s*false/);
119134
assert.match(appScript, /pendingProviderSwitch:\s*''/);
120-
assert.match(appScript, /const SESSION_TRASH_LIST_LIMIT = 200;/);
135+
assert.match(appScript, /const SESSION_TRASH_LIST_LIMIT = 500;/);
136+
assert.match(appScript, /const SESSION_TRASH_PAGE_SIZE = 200;/);
121137
assert.match(appScript, /settingsTab:\s*'backup'/);
122138
assert.match(appScript, /sessionTrashItems:\s*\[\]/);
139+
assert.match(appScript, /sessionTrashVisibleCount:\s*SESSION_TRASH_PAGE_SIZE/);
123140
assert.match(appScript, /sessionTrashTotalCount:\s*0/);
124141
assert.match(appScript, /sessionTrashLoadedOnce:\s*false/);
125142
assert.match(appScript, /sessionTrashLoading:\s*false/);
126143
assert.match(appScript, /const totalCount = Number\(this\.sessionTrashTotalCount\);/);
144+
assert.match(appScript, /visibleSessionTrashItems\(\)/);
145+
assert.match(appScript, /sessionTrashHasMoreItems\(\)/);
146+
assert.match(appScript, /sessionTrashHiddenCount\(\)/);
127147
assert.match(appScript, /normalizeSettingsTab\(tab\)/);
128148
assert.match(appScript, /switchSettingsTab\(tab,\s*options = \{\}\)/);
129149
assert.match(appScript, /loadSessionTrash\(options = \{\}\)/);
150+
assert.match(appScript, /loadMoreSessionTrashItems\(\)/);
130151
assert.match(appScript, /restoreSessionTrash\(item\)/);
131152
assert.match(appScript, /purgeSessionTrash\(item\)/);
132153
assert.match(appScript, /clearSessionTrash\(\)/);
133154
assert.match(appScript, /buildSessionTrashItemFromSession\(session,\s*result = \{\}\)/);
134-
assert.match(appScript, /prependSessionTrashItem\(item\)/);
155+
assert.match(appScript, /prependSessionTrashItem\(item,\s*options = \{\}\)/);
156+
assert.match(appScript, /resetSessionTrashVisibleCount\(\)/);
135157
assert.match(appScript, /normalizeSessionTrashTotalCount\(totalCount,\s*fallbackItems = this\.sessionTrashItems\)/);
136158
assert.match(appScript, /getSessionTrashViewState\(\)/);
137159
assert.match(appScript, /this\.sessionTrashTotalCount = this\.normalizeSessionTrashTotalCount\(res\.totalCount,\s*nextItems\);/);
160+
assert.match(appScript, /this\.sessionTrashTotalCount = this\.normalizeSessionTrashTotalCount\(\s*res && res\.totalCount !== undefined/);
138161
assert.match(appScript, /messageCount:\s*Number\.isFinite\(Number\(result && result\.messageCount\)\)/);
139162
assert.match(appScript, /clearActiveSessionState\(\)/);
140163
assert.match(appScript, /removeSessionFromCurrentList\(session\)/);
@@ -159,11 +182,13 @@ test('session helper deferred claude refresh validates live tab and mode before
159182
assert.match(helperScript, /forceRefresh: this\.settingsTab === 'trash' && !!this\.sessionTrashLoadedOnce/);
160183
assert.match(helperScript, /const shouldPrimeTrashCountOnSettingsEnter = nextTab === 'settings'/);
161184
assert.match(helperScript, /this\.settingsTab !== 'trash'/);
185+
assert.match(helperScript, /this\.sessionTrashLoadedOnce = false;/);
162186
assert.match(helperScript, /this\.loadSessionTrashCount\(\{ silent: true \}\);/);
163187
});
164188

165189
test('trash item styles stay aligned with session card layout and keep mobile usability', () => {
166190
const styles = readProjectFile('web-ui/styles.css');
191+
assert.match(styles, /\.session-source\s*\{/);
167192
assert.match(styles, /\.trash-item\.session-item\s*\{[\s\S]*height:\s*auto;/);
168193
assert.match(styles, /\.trash-item-title\s*\{[\s\S]*-webkit-line-clamp:\s*2;/);
169194
assert.match(styles, /\.trash-item-side\s*\{[\s\S]*min-width:\s*132px;/);

tests/unit/session-tab-switch-performance.test.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,12 @@ test('switchMainTab keeps claude model context refresh behavior', () => {
104104
assert.strictEqual(refreshCount, 1);
105105
});
106106

107-
test('switchMainTab primes trash badge count when entering settings before trash tab is opened', () => {
107+
test('switchMainTab primes trash badge count and invalidates the cached trash list when entering settings before trash tab is opened', () => {
108108
const calls = [];
109109
const vm = {
110110
mainTab: 'sessions',
111111
settingsTab: 'backup',
112-
sessionTrashLoadedOnce: false,
112+
sessionTrashLoadedOnce: true,
113113
configMode: 'codex',
114114
teardownSessionTabRender() {},
115115
prepareSessionTabRender() {},
@@ -123,6 +123,7 @@ test('switchMainTab primes trash badge count when entering settings before trash
123123
switchMainTab.call(vm, 'settings');
124124

125125
assert.strictEqual(vm.mainTab, 'settings');
126+
assert.strictEqual(vm.sessionTrashLoadedOnce, false);
126127
assert.deepStrictEqual(calls, [{ silent: true }]);
127128
});
128129

0 commit comments

Comments
 (0)