Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3fca3d7
feat: support gemini cli sessions
ymkiux Apr 28, 2026
dfdb9f9
fix: session source dropdown uses latest selection
ymkiux Apr 28, 2026
50c86df
fix: reload sessions after restoring gemini filter
ymkiux Apr 28, 2026
db8422c
fix(ui): keep sessions header actions inline
ymkiux Apr 28, 2026
40ce1d1
fix(ui): force sessions header buttons inline
ymkiux Apr 28, 2026
4004b99
fix(ui): prevent sessions header actions wrapping
ymkiux Apr 28, 2026
dba4a2e
test(ui): lock sessions header actions inline
ymkiux Apr 28, 2026
41f053f
fix(ui): prevent sessions header buttons stacking
ymkiux Apr 28, 2026
0827714
fix(ui): align sessions header action buttons
ymkiux Apr 28, 2026
15169c2
docs: add gemini cli install and session browser support
ymkiux Apr 28, 2026
cc8f37c
feat: add codebuddy code session browser support
ymkiux Apr 28, 2026
a4c681c
fix: initialize codebuddy session lookup cache
Apr 29, 2026
d149b07
fix: sync sessions current source label
ymkiux Apr 29, 2026
bfd07ab
fix: update cli install docs copy for gemini/codebuddy
ymkiux Apr 29, 2026
3d263d3
feat: support codebuddy resume command copy
ymkiux Apr 29, 2026
6d4de6d
feat: support gemini resume command copy
ymkiux Apr 29, 2026
4526446
docs: document resume command copy for gemini/codebuddy
ymkiux Apr 29, 2026
336432a
docs: expand session management docs
Apr 29, 2026
6be02b4
fix(ui): keep session list titles single-line
ymkiux Apr 29, 2026
1333ca2
fix(ui): tighten session toolbar spacing
ymkiux Apr 29, 2026
75756c0
feat(usage): add current session summary bar
ymkiux Apr 29, 2026
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
632 changes: 565 additions & 67 deletions cli.js

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions cli/session-usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ async function listSessionUsageCore(params = {}, deps = {}) {
listSessionBrowse,
parseCodexSessionSummary,
parseClaudeSessionSummary,
parseGeminiSessionSummary,
MAX_SESSION_USAGE_LIST_SIZE,
SESSION_BROWSE_SUMMARY_READ_BYTES
} = deps;

const source = params.source === 'codex' || params.source === 'claude'
const source = params.source === 'codex' || params.source === 'claude' || params.source === 'gemini'
? params.source
: 'all';
const rawLimit = Number(params.limit);
Expand Down Expand Up @@ -81,7 +82,9 @@ async function listSessionUsageCore(params = {}, deps = {}) {
try {
summary = normalized.source === 'claude'
? parseClaudeSessionSummary(filePath, summaryOptions)
: parseCodexSessionSummary(filePath, summaryOptions);
: (normalized.source === 'gemini'
? parseGeminiSessionSummary(filePath, summaryOptions)
: parseCodexSessionSummary(filePath, summaryOptions));
} catch (_) {
summary = null;
}
Expand Down
18 changes: 17 additions & 1 deletion tests/e2e/test-sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const fs = require('fs');
const { assert } = require('./helpers');

module.exports = async function testSessions(ctx) {
const { api, sessionId, tmpHome, claudeSessionId, sessionPath, claudeSessionPath } = ctx;
const { api, sessionId, tmpHome, claudeSessionId, sessionPath, claudeSessionPath, geminiSessionId, geminiSessionPath } = ctx;
const buildTimestamp = (baseIso, offsetSeconds) => new Date(Date.parse(baseIso) + (offsetSeconds * 1000)).toISOString();
const bestEffortApi = async (action, params) => {
try {
Expand All @@ -24,11 +24,17 @@ module.exports = async function testSessions(ctx) {
assert(Array.isArray(apiSessionsClaude.sessions), 'api sessions(claude) missing');
assert(apiSessionsClaude.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(claude) missing claude entry');

// ========== List Sessions Tests - Gemini ==========
const apiSessionsGemini = await api('list-sessions', { source: 'gemini', limit: 50, forceRefresh: true });
assert(Array.isArray(apiSessionsGemini.sessions), 'api sessions(gemini) missing');
assert(apiSessionsGemini.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(gemini) missing gemini entry');

// ========== List Sessions Tests - All Sources ==========
const apiSessionsAll = await api('list-sessions', { source: 'all', limit: 50, forceRefresh: true });
assert(Array.isArray(apiSessionsAll.sessions), 'api sessions(all) missing');
assert(apiSessionsAll.sessions.some(item => item.sessionId === sessionId), 'api sessions(all) missing codex entry');
assert(apiSessionsAll.sessions.some(item => item.sessionId === claudeSessionId), 'api sessions(all) missing claude entry');
assert(apiSessionsAll.sessions.some(item => item.sessionId === geminiSessionId), 'api sessions(all) missing gemini entry');

// ========== List Sessions Tests - Invalid Source ==========
const apiSessionsInvalid = await api('list-sessions', { source: 'invalid', limit: 50 });
Expand Down Expand Up @@ -75,6 +81,10 @@ module.exports = async function testSessions(ctx) {
const sessionDetailClaude = await api('session-detail', { source: 'claude', sessionId: claudeSessionId });
assert(Array.isArray(sessionDetailClaude.messages), 'session-detail(claude) missing messages');

const sessionDetailGemini = await api('session-detail', { source: 'gemini', sessionId: geminiSessionId });
assert(Array.isArray(sessionDetailGemini.messages), 'session-detail(gemini) missing messages');
assert(sessionDetailGemini.messages.some((m) => String(m.text || '').includes('hello from codexmate')), 'session-detail(gemini) content mismatch');

const sessionDetailMissing = await api('session-detail', { source: 'codex', sessionId: 'missing-session' });
assert(sessionDetailMissing.error, 'session-detail should fail for missing session');

Expand All @@ -86,6 +96,9 @@ module.exports = async function testSessions(ctx) {
assert(sessionPlain.text && sessionPlain.text.includes('world'), 'session-plain missing content');
assert(typeof sessionPlain.text === 'string', 'session-plain text missing');

const sessionPlainGemini = await api('session-plain', { source: 'gemini', sessionId: geminiSessionId });
assert(sessionPlainGemini.text && sessionPlainGemini.text.includes('hello from codexmate'), 'session-plain(gemini) missing content');

const sessionPlainMissing = await api('session-plain', { source: 'codex', sessionId: 'missing-session' });
assert(sessionPlainMissing.error, 'session-plain should fail for missing session');

Expand All @@ -102,6 +115,9 @@ module.exports = async function testSessions(ctx) {
assert(exportSessionFull.content, 'export-session(full) missing content');
assert(exportSessionFull.truncated === false, 'export-session(full) should not be truncated');

const exportSessionGemini = await api('export-session', { source: 'gemini', sessionId: geminiSessionId, maxMessages: 100 });
assert(exportSessionGemini.content, 'export-session(gemini) missing content');

const exportSessionMissing = await api('export-session', { source: 'codex', sessionId: 'missing', maxMessages: 10 });
assert(exportSessionMissing.error, 'export-session should fail for missing session');

Expand Down
18 changes: 18 additions & 0 deletions tests/e2e/test-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ module.exports = async function testSetup(ctx) {
};
fs.writeFileSync(claudeIndexPath, JSON.stringify(claudeIndex, null, 2), 'utf-8');

const geminiProjectHash = 'e2e-gemini-project';
const geminiChatsDir = path.join(tmpHome, '.gemini', 'tmp', geminiProjectHash, 'chats');
fs.mkdirSync(geminiChatsDir, { recursive: true });
const geminiSessionId = 'gemini-e2e-session';
const geminiSessionPath = path.join(geminiChatsDir, `${geminiSessionId}.json`);
const geminiSession = {
sessionId: geminiSessionId,
startTime: '2025-02-15T00:00:00.000Z',
lastUpdated: '2025-02-15T00:00:02.000Z',
messages: [
{ type: 'user', content: 'hello from gemini cli session', timestamp: '2025-02-15T00:00:01.000Z' },
{ type: 'gemini', content: 'hello from codexmate', timestamp: '2025-02-15T00:00:02.000Z' }
]
};
fs.writeFileSync(geminiSessionPath, JSON.stringify(geminiSession, null, 2), 'utf-8');

Object.assign(ctx, {
claudeModel,
sessionId,
Expand All @@ -216,6 +232,8 @@ module.exports = async function testSetup(ctx) {
lateKeywordMessage,
claudeSessionId,
claudeSessionPath,
geminiSessionId,
geminiSessionPath,
noModelsUrl,
htmlModelsUrl,
authFailUrl
Expand Down
110 changes: 110 additions & 0 deletions tests/unit/session-browser-timeline-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { fileURLToPath, pathToFileURL } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const { withGlobalOverrides } = await import(
pathToFileURL(path.join(__dirname, 'helpers', 'web-ui-app-options.mjs'))
);

const { createSessionBrowserMethods } = await import(
pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.session-browser.mjs'))
);
Expand All @@ -29,6 +33,112 @@ test('loadSessionPathOptions clears visible loading state when reusing cached so
assert.strictEqual(context.sessionPathOptionsLoading, false);
});

test('onSessionSourceChange prefers event target value over stale state', async () => {
const methods = createSessionBrowserMethods({
api: async () => ({ sessions: [] })
});
let loadCalls = 0;
const context = {
sessionFilterSource: 'codex',
sessionPathFilter: '',
refreshSessionPathOptions() {},
persistSessionFilterCache() {},
loadSessions: async () => {
loadCalls += 1;
}
};

await methods.onSessionSourceChange.call(context, { target: { value: 'gemini' } });

assert.strictEqual(context.sessionFilterSource, 'gemini');
assert.strictEqual(loadCalls, 1);
});

test('restoreSessionFilterCache triggers reload when url state sets source', async () => {
const methods = createSessionBrowserMethods({
api: async () => ({ sessions: [] })
});
let loadCalls = 0;
const context = {
mainTab: 'sessions',
sessionFilterSource: 'codex',
sessionPathFilter: '',
sessionQuery: '',
sessionRoleFilter: 'all',
sessionTimePreset: 'all',
refreshSessionPathOptions() {},
loadSessions: async () => {
loadCalls += 1;
}
};
const localStorage = {
getItem() { return null; },
setItem() {},
removeItem() {}
};
const window = {
location: {
href: 'http://localhost/?tab=sessions&s_source=gemini',
pathname: '/',
search: '?tab=sessions&s_source=gemini'
},
history: {
replaceState() {}
}
};

await withGlobalOverrides({ window, localStorage }, async () => {
await methods.restoreSessionFilterCache.call(context);
});

assert.strictEqual(context.sessionFilterSource, 'gemini');
assert.strictEqual(loadCalls, 1);
});

test('restoreSessionFilterCache triggers reload when stored source is not default', async () => {
const methods = createSessionBrowserMethods({
api: async () => ({ sessions: [] })
});
let loadCalls = 0;
const context = {
mainTab: 'sessions',
sessionFilterSource: 'all',
sessionPathFilter: '',
sessionQuery: '',
sessionRoleFilter: 'all',
sessionTimePreset: 'all',
refreshSessionPathOptions() {},
loadSessions: async () => {
loadCalls += 1;
}
};
const localStorage = {
getItem(key) {
if (key === 'codexmateSessionFilterSource') return 'gemini';
return null;
},
setItem() {},
removeItem() {}
};
const window = {
location: {
href: 'http://localhost/?tab=sessions',
pathname: '/',
search: '?tab=sessions'
},
history: {
replaceState() {}
}
};

await withGlobalOverrides({ window, localStorage }, async () => {
await methods.restoreSessionFilterCache.call(context);
});

assert.strictEqual(context.sessionFilterSource, 'gemini');
assert.strictEqual(loadCalls, 1);
});

test('selectSession defers detail loading until the next frame when a scheduler is available', async () => {
const methods = createSessionBrowserMethods({
api: async () => ({})
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/session-usage-backend.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const parseClaudeSessionSummarySrc = extractFunction(cliContent, 'parseClaudeSes
function instantiateListSessionUsage(bindings = {}) {
const effectiveBindings = {
listSessionUsageCore: usageCore.listSessionUsageCore,
parseGeminiSessionSummary() {
throw new Error('should not parse gemini summary in this test');
},
...(bindings || {})
};
const bindingNames = Object.keys(effectiveBindings);
Expand Down
9 changes: 6 additions & 3 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,20 @@ document.addEventListener('DOMContentLoaded', () => {
sessionPathOptionsMap: {
all: [],
codex: [],
claude: []
claude: [],
gemini: []
},
sessionPathOptionsLoadedMap: {
all: false,
codex: false,
claude: false
claude: false,
gemini: false
},
sessionPathRequestSeqMap: {
all: 0,
codex: 0,
claude: 0
claude: 0,
gemini: 0
},
sessionExporting: {},
sessionCloning: {},
Expand Down
4 changes: 2 additions & 2 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ function shouldUseFastSessionBrowseLimit(options = {}) {

export function isSessionQueryEnabled(source) {
const normalized = normalizeSessionSource(source, '');
return normalized === 'codex' || normalized === 'claude' || normalized === 'all';
return normalized === 'codex' || normalized === 'claude' || normalized === 'gemini' || normalized === 'all';

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

Query capability changed, but UI copy now appears stale.

With Line 32 enabling Gemini queries, the current placeholder text (“supports Codex/Claude”) becomes inaccurate for Gemini source selection. Please update the enabled-query i18n copy accordingly.

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

In `@web-ui/logic.sessions.mjs` at line 32, The UI copy for the enabled-query i18n
key is stale after adding Gemini support; update the i18n string used when
normalized queries include 'gemini' (the same logic branch that returns
normalized === 'codex' || normalized === 'claude' || normalized === 'gemini' ||
normalized === 'all') to mention Gemini (e.g., “supports Codex/Claude/Gemini” or
equivalent), and ensure any placeholder or tooltip that references enabled-query
reflects the new option.

}

export function normalizeSessionSource(source, fallback = 'all') {
const normalized = typeof source === 'string'
? source.trim().toLowerCase()
: '';
if (normalized === 'codex' || normalized === 'claude' || normalized === 'all') {
if (normalized === 'codex' || normalized === 'claude' || normalized === 'gemini' || normalized === 'all') {
return normalized;
}
return fallback;
Expand Down
1 change: 1 addition & 0 deletions web-ui/modules/app.computed.dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function createDashboardComputed() {
inspectorSessionSourceLabel() {
if (this.sessionFilterSource === 'codex') return this.t('dashboard.sessionSource.codex');
if (this.sessionFilterSource === 'claude') return this.t('dashboard.sessionSource.claude');
if (this.sessionFilterSource === 'gemini') return this.t('dashboard.sessionSource.gemini');
return this.t('dashboard.sessionSource.all');
},
inspectorSessionPathLabel() {
Expand Down
32 changes: 28 additions & 4 deletions web-ui/modules/app.methods.session-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export function createSessionBrowserMethods(options = {}) {
},

syncSessionPathOptionsForSource(source, nextOptions, mergeWithExisting = false) {
const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex');
const targetSource = source === 'claude'
? 'claude'
: (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex'));
const current = Array.isArray(this.sessionPathOptionsMap[targetSource])
? this.sessionPathOptionsMap[targetSource]
: [];
Expand All @@ -142,7 +144,9 @@ export function createSessionBrowserMethods(options = {}) {
},

refreshSessionPathOptions(source) {
const targetSource = source === 'claude' ? 'claude' : (source === 'all' ? 'all' : 'codex');
const targetSource = source === 'claude'
? 'claude'
: (source === 'gemini' ? 'gemini' : (source === 'all' ? 'all' : 'codex'));
const base = Array.isArray(this.sessionPathOptionsMap[targetSource])
? [...this.sessionPathOptionsMap[targetSource]]
: [];
Expand All @@ -164,7 +168,9 @@ export function createSessionBrowserMethods(options = {}) {
},

async loadSessionPathOptions(options = {}) {
const source = options.source === 'claude' ? 'claude' : (options.source === 'all' ? 'all' : 'codex');
const source = options.source === 'claude'
? 'claude'
: (options.source === 'gemini' ? 'gemini' : (options.source === 'all' ? 'all' : 'codex'));
const forceRefresh = !!options.forceRefresh;
const loaded = !!this.sessionPathOptionsLoadedMap[source];
if (!forceRefresh && loaded) {
Expand Down Expand Up @@ -252,6 +258,9 @@ export function createSessionBrowserMethods(options = {}) {
const urlState = readSessionsFilterUrlState();
if (urlState) {
applySessionsFilterUrlState(this, urlState);
if (this.mainTab === 'sessions' && typeof this.loadSessions === 'function') {
void this.loadSessions();
}
return;
}
const sourceCache = localStorage.getItem('codexmateSessionFilterSource');
Expand All @@ -266,6 +275,16 @@ export function createSessionBrowserMethods(options = {}) {
this.sessionRoleFilter = normalizeSessionRoleFilter(roleCache);
this.sessionTimePreset = normalizeSessionTimePreset(timeCache);
this.refreshSessionPathOptions(this.sessionFilterSource);
if (this.mainTab === 'sessions' && typeof this.loadSessions === 'function') {
const shouldReload = cached.source !== 'all'
|| !!cached.pathFilter
|| !!(this.sessionQuery && isSessionQueryEnabled(cached.source))
|| (this.sessionRoleFilter && this.sessionRoleFilter !== 'all')
|| (this.sessionTimePreset && this.sessionTimePreset !== 'all');
if (shouldReload) {
void this.loadSessions();
}
}
},

persistSessionFilterCache() {
Expand Down Expand Up @@ -412,7 +431,12 @@ export function createSessionBrowserMethods(options = {}) {
this.persistSessionPinnedMap();
},

async onSessionSourceChange() {
async onSessionSourceChange(event) {
const rawValue = event && event.target && typeof event.target.value === 'string'
? event.target.value
: this.sessionFilterSource;
const cached = buildSessionFilterCacheState(rawValue, this.sessionPathFilter);
this.sessionFilterSource = cached.source;
this.refreshSessionPathOptions(this.sessionFilterSource);
this.persistSessionFilterCache();
syncSessionsFilterUrl(this);
Expand Down
6 changes: 4 additions & 2 deletions web-ui/modules/app.methods.session-trash.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export function createSessionTrashMethods(options = {}) {
const deletedAt = typeof result.deletedAt === 'string' && result.deletedAt
? result.deletedAt
: new Date().toISOString();
const source = session && session.source === 'claude' ? 'claude' : 'codex';
const source = session && (session.source === 'claude' || session.source === 'gemini')
? session.source
: 'codex';
return {
trashId: typeof result.trashId === 'string' ? result.trashId : '',
source,
sourceLabel: session && typeof session.sourceLabel === 'string' && session.sourceLabel
? session.sourceLabel
: (source === 'claude' ? 'Claude Code' : 'Codex'),
: (source === 'claude' ? 'Claude Code' : (source === 'gemini' ? 'Gemini CLI' : 'Codex')),
sessionId: session && typeof session.sessionId === 'string' ? session.sessionId : '',
title: session && typeof session.title === 'string' && session.title
? session.title
Expand Down
Loading
Loading