Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
85 changes: 85 additions & 0 deletions tests/unit/session-trash-state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,7 @@ 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');
},
removeSessionPin() {},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
async removeSessionFromCurrentList() {
removed = true;
},
Expand Down Expand Up @@ -1704,6 +1705,7 @@ test('deleteSession prefers authoritative trash totalCount from the backend resp
buildSessionTrashItemFromSession() {
throw new Error('loaded-list branch should not run in count-only test');
},
removeSessionPin() {},
async removeSessionFromCurrentList() {},
showMessage() {}
};
Expand Down Expand Up @@ -1748,6 +1750,89 @@ test('prependSessionTrashItem prefers authoritative trash totalCount when provid
assert.strictEqual(context.sessionTrashTotalCount, 500);
});

test('pruneSessionPinnedMap removes stale pinned session keys', () => {
const pruneSessionPinnedMap = instantiateFunction(
extractMethodAsFunction(appSource, 'pruneSessionPinnedMap'),
'pruneSessionPinnedMap'
);

let persisted = 0;
const context = {
sessionPinnedMap: {
'codex:keep': 111,
'codex:stale': 222
},
sessionsList: [
{ key: 'codex:keep' }
],
getSessionExportKey(session) {
return session && session.key;
},
persistSessionPinnedMap() {
persisted += 1;
}
};

pruneSessionPinnedMap.call(context);

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

test('restoreSessionPinnedMap normalizes cache and prunes stale entries', () => {
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 };
},
pruneSessionPinnedMap: null
};

context.pruneSessionPinnedMap = instantiateFunction(
extractMethodAsFunction(appSource, 'pruneSessionPinnedMap'),
'pruneSessionPinnedMap'
).bind(context);

restoreSessionPinnedMap.call(context);

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

test('loadSessionTrash replays the latest queued refresh after an in-flight request is invalidated', async () => {
const loadSessionTrashSource = extractMethodAsFunction(appSource, 'loadSessionTrash');
const pendingResponses = [];
Expand Down
132 changes: 131 additions & 1 deletion web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
skillsImporting: false,
skillsZipImporting: false,
skillsExporting: false,
sessionPinnedMap: {},
sessionsList: [],
sessionsLoadedOnce: false,
sessionsLoading: false,
Expand Down Expand Up @@ -405,6 +406,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
this.sessionResumeWithYolo = true;
}
this.restoreSessionFilterCache();
this.restoreSessionPinnedMap();
window.addEventListener('resize', this.onWindowResize);
window.addEventListener('keydown', this.handleGlobalKeydown);
window.addEventListener('beforeunload', this.handleBeforeUnload);
Expand Down Expand Up @@ -464,6 +466,33 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
activeSessionExportKey() {
return this.activeSession ? this.getSessionExportKey(this.activeSession) : '';
},
sortedSessionsList() {
const list = Array.isArray(this.sessionsList) ? this.sessionsList : [];
if (list.length === 0) return [];
const pinnedMap = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object')
? this.sessionPinnedMap
: {};
let hasPinned = false;
const decorated = list.map((session, index) => {
const key = session ? this.getSessionExportKey(session) : '';
const rawPinnedAt = key ? pinnedMap[key] : 0;
const pinnedAt = Number.isFinite(Number(rawPinnedAt))
? Math.floor(Number(rawPinnedAt))
: 0;
const isPinned = pinnedAt > 0;
if (isPinned) {
hasPinned = true;
}
return { session, index, pinnedAt, isPinned };
});
if (!hasPinned) return list;
decorated.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
if (a.isPinned && a.pinnedAt !== b.pinnedAt) return b.pinnedAt - a.pinnedAt;
return a.index - b.index;
});
return decorated.map(item => item.session);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
activeSessionVisibleMessages() {
if (this.mainTab !== 'sessions' || !this.sessionPreviewRenderEnabled) {
return [];
Expand Down Expand Up @@ -1823,6 +1852,7 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
this.showMessage(res.error, 'error');
return;
}
this.removeSessionPin(session);
this.invalidateSessionTrashRequests();
this.showMessage('已移入回收站', 'success');
if (this.sessionTrashLoadedOnce) {
Expand Down Expand Up @@ -2379,6 +2409,104 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
localStorage.removeItem('codexmateSessionPathFilter');
}
},
normalizeSessionPinnedMap(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return {};
}
const next = {};
for (const [key, value] of Object.entries(raw)) {
if (!key) continue;
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) continue;
next[key] = Math.floor(numeric);
}
return next;
},
restoreSessionPinnedMap() {
const cached = localStorage.getItem('codexmateSessionPinnedMap');
if (!cached) {
this.sessionPinnedMap = {};
return;
}
try {
const parsed = JSON.parse(cached);
this.sessionPinnedMap = this.normalizeSessionPinnedMap(parsed);
this.pruneSessionPinnedMap();
} catch (_) {
this.sessionPinnedMap = {};
localStorage.removeItem('codexmateSessionPinnedMap');
}
},
persistSessionPinnedMap() {
const payload = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object')
? this.sessionPinnedMap
: {};
localStorage.setItem('codexmateSessionPinnedMap', JSON.stringify(payload));
},
pruneSessionPinnedMap(sessions = this.sessionsList) {
const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object')
? this.sessionPinnedMap
: {};
const list = Array.isArray(sessions) ? sessions : [];
if (Object.keys(current).length === 0) {
return;
}
const validKeys = new Set(list.map((session) => this.getSessionExportKey(session)).filter(Boolean));
const next = {};
let changed = false;
for (const [key, value] of Object.entries(current)) {
if (!validKeys.has(key)) {
changed = true;
continue;
}
next[key] = value;
}
if (!changed) {
return;
}
this.sessionPinnedMap = next;
this.persistSessionPinnedMap();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
getSessionPinTimestamp(session) {
if (!session) return 0;
const key = this.getSessionExportKey(session);
if (!key) return 0;
const raw = this.sessionPinnedMap && this.sessionPinnedMap[key];
const numeric = Number(raw);
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
},
isSessionPinned(session) {
return this.getSessionPinTimestamp(session) > 0;
},
toggleSessionPin(session) {
if (!session) return;
const key = this.getSessionExportKey(session);
if (!key) return;
const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object')
? this.sessionPinnedMap
: {};
const next = { ...current };
if (next[key]) {
delete next[key];
} else {
next[key] = Date.now();
}
this.sessionPinnedMap = next;
this.persistSessionPinnedMap();
},
removeSessionPin(session) {
if (!session) return;
const key = this.getSessionExportKey(session);
if (!key) return;
const current = (this.sessionPinnedMap && typeof this.sessionPinnedMap === 'object')
? this.sessionPinnedMap
: {};
if (!current[key]) return;
const next = { ...current };
delete next[key];
this.sessionPinnedMap = next;
this.persistSessionPinnedMap();
},

async onSessionSourceChange() {
this.refreshSessionPathOptions(this.sessionFilterSource);
Expand Down Expand Up @@ -2868,7 +2996,9 @@ import { createSkillsMethods } from './modules/skills.methods.mjs';
},

async loadSessions() {
return loadSessionsHelper.call(this, api);
const result = await loadSessionsHelper.call(this, api);
this.pruneSessionPinnedMap();
return result;
},

async selectSession(session) {
Expand Down
21 changes: 18 additions & 3 deletions web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -904,13 +904,14 @@ <h1 class="main-title">
<div v-else :class="['session-layout', { 'session-standalone': sessionStandalone }]">
<div v-if="!sessionStandalone && sessionListRenderEnabled" class="session-list">
<div
v-for="session in sessionsList"
v-for="session in sortedSessionsList"
:key="session.source + '-' + session.sessionId + '-' + session.filePath"
v-memo="[activeSessionExportKey === getSessionExportKey(session), session.messageCount, session.updatedAt, session.title, session.sourceLabel]"
v-memo="[activeSessionExportKey === getSessionExportKey(session), session.messageCount, session.updatedAt, session.title, session.sourceLabel, isSessionPinned(session)]"
:class="[
'session-item',
{
active: activeSessionExportKey === getSessionExportKey(session)
active: activeSessionExportKey === getSessionExportKey(session),
pinned: isSessionPinned(session)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
]"
@click="selectSession(session)">
Expand All @@ -920,6 +921,20 @@ <h1 class="main-title">
<span class="session-count-badge">{{ session.messageCount ?? 0 }}</span>
</div>
<div class="session-item-actions">
<button
class="session-item-copy session-item-pin"
@click.stop="toggleSessionPin(session)"
:disabled="sessionsLoading"
:aria-label="isSessionPinned(session) ? '取消置顶' : '置顶'"
:title="isSessionPinned(session) ? '取消置顶' : '置顶'"
:aria-pressed="isSessionPinned(session)">
<svg v-if="isSessionPinned(session)" class="pin-icon" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.6">
<path d="M12 22s8-6 8-12a8 8 0 1 0-16 0c0 6 8 12 8 12z"></path>
</svg>
<svg v-else class="pin-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M12 22s8-6 8-12a8 8 0 1 0-16 0c0 6 8 12 8 12z"></path>
</svg>
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button
v-if="isResumeCommandAvailable(session)"
class="session-item-copy"
Expand Down
30 changes: 30 additions & 0 deletions web-ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,16 @@ body::after {
box-shadow: 0 6px 16px rgba(210, 107, 90, 0.12);
}

.session-item.pinned {
border-color: rgba(208, 88, 58, 0.42);
background: linear-gradient(to bottom, rgba(210, 107, 90, 0.12) 0%, rgba(255, 251, 247, 0.98) 100%);
box-shadow: 0 8px 18px rgba(210, 107, 90, 0.10);
}

.session-item.pinned::before {
background: linear-gradient(180deg, rgba(201, 94, 75, 0.8), rgba(201, 94, 75, 0.32));
}

.session-item.active::before {
background: linear-gradient(180deg, rgba(201, 94, 75, 0.9), rgba(201, 94, 75, 0.4));
}
Expand Down Expand Up @@ -2325,6 +2335,26 @@ body::after {
height: 16px;
}

.session-item-pin {
border-color: rgba(208, 88, 58, 0.24);
}

.session-item-pin .pin-icon,
.session-item-pin svg {
color: rgba(208, 88, 58, 0.78);
}

.session-item.pinned .session-item-pin {
background: rgba(208, 88, 58, 0.16);
border-color: rgba(208, 88, 58, 0.46);
box-shadow: inset 0 0 0 1px rgba(208, 88, 58, 0.08);
}

.session-item.pinned .session-item-pin .pin-icon,
.session-item.pinned .session-item-pin svg {
color: var(--color-brand-dark);
}

.session-item-sub.session-item-snippet,
.session-preview-meta,
.session-preview-title {
Expand Down
Loading