Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
40 changes: 21 additions & 19 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -4879,27 +4879,29 @@ function listClaudeSessions(limit, options = {}) {
}
}

if (sessions.length === 0) {
const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
returnCount: scanCount,
maxFilesScanned,
ignoreSubPath: `${path.sep}subagents${path.sep}`
// 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
returnCount: scanCount,
maxFilesScanned,
ignoreSubPath: `${path.sep}subagents${path.sep}`
});
Comment on lines +4882 to +4888

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 | 🟠 Major | ⚡ Quick win

Avoid unconditional fallback directory scan when targetCount is already met.

This now always traverses claudeProjectsDir, even if sessions.length has already reached targetCount. On large trees, that adds avoidable synchronous I/O and slows session list responses.

⚡ Suggested fix
-    // 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
-    const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
-    const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
-        returnCount: scanCount,
-        maxFilesScanned,
-        ignoreSubPath: `${path.sep}subagents${path.sep}`
-    });
-    for (const filePath of fallbackFiles) {
+    // 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
+    if (sessions.length < targetCount) {
+        const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
+        const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
+            returnCount: scanCount,
+            maxFilesScanned,
+            ignoreSubPath: `${path.sep}subagents${path.sep}`
+        });
+        for (const filePath of fallbackFiles) {
             if (seenFilePaths.has(filePath)) continue;
             const summary = parseClaudeSessionSummary(filePath, {
                 summaryReadBytes,
                 titleReadBytes
-        });
-        if (summary) {
-            sessions.push(attachSessionNativeStatus({
-                ...summary,
-                derived: isDerivedSessionFile(filePath)
-            }));
-            seenFilePaths.add(filePath);
-        }
+            });
+            if (summary) {
+                sessions.push(attachSessionNativeStatus({
+                    ...summary,
+                    derived: isDerivedSessionFile(filePath)
+                }));
+                seenFilePaths.add(filePath);
+            }
 
-        if (sessions.length >= targetCount) {
-            break;
+            if (sessions.length >= targetCount) {
+                break;
+            }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli.js` around lines 4882 - 4888, The code always calls
collectRecentJsonlFiles(claudeProjectsDir, { returnCount: scanCount,
maxFilesScanned, ignoreSubPath: ... }) even when sessions.length already meets
targetCount, causing unnecessary I/O; change the logic around seenFilePaths and
fallbackFiles so you only invoke collectRecentJsonlFiles when sessions.length <
targetCount (compute needed = targetCount - sessions.length and pass that as
returnCount instead of unconditional scanCount), and keep using seenFilePaths to
dedupe returned files; update any variable names used (scanCount/needed) so
collectRecentJsonlFiles is skipped when not needed.

for (const filePath of fallbackFiles) {
if (seenFilePaths.has(filePath)) continue;
const summary = parseClaudeSessionSummary(filePath, {
summaryReadBytes,
titleReadBytes
});
for (const filePath of fallbackFiles) {
const summary = parseClaudeSessionSummary(filePath, {
summaryReadBytes,
titleReadBytes
});
if (summary) {
sessions.push(attachSessionNativeStatus({
...summary,
derived: isDerivedSessionFile(filePath)
}));
}
if (summary) {
sessions.push(attachSessionNativeStatus({
...summary,
derived: isDerivedSessionFile(filePath)
}));
seenFilePaths.add(filePath);
}

if (sessions.length >= targetCount) {
break;
}
if (sessions.length >= targetCount) {
break;
}
}

Expand Down
2 changes: 2 additions & 0 deletions tests/unit/web-ui-behavior-parity.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
const missingCurrentKeys = headDataKeys.filter((key) => !currentDataKeys.includes(key)).sort();
const allowedExtraCurrentKeys = parityAgainstHead ? [
'appVersion',
'brandHovered',
'sessionListInitialBatchSize',
'sessionListLoadStep',
'sessionListVisibleCount',
Expand Down Expand Up @@ -354,6 +355,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus
'showEditProviderKey'
] : [
'appVersion',
'brandHovered',
'__mainTabSwitchState',
'openclawAuthProfilesByProvider',
'openclawPendingAuthProfileUpdates',
Expand Down
1 change: 1 addition & 0 deletions web-ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ document.addEventListener('DOMContentLoaded', () => {
const appOptions = {
data() {
return {
brandHovered: false,
lang: 'zh',
appVersion: '',
mainTab: 'dashboard',
Expand Down
78 changes: 67 additions & 11 deletions web-ui/logic.sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,73 @@ function clampTimelinePercent(percent) {
return Math.max(6, Math.min(94, percent));
}

export function formatSessionTimelineTimestamp(timestamp) {
export function formatSessionTimelineTimestamp(timestamp, t = null, lang = 'zh') {
const value = typeof timestamp === 'string' ? timestamp.trim() : '';
if (!value) return '';

const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/);
if (matched) {
if (!matched) return value;

// 无i18n时使用原格式
if (typeof t !== 'function') {
const second = matched[6] || '00';
return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`;
}

return value;
// 相对时间格式
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
const hour = Number(matched[4]);
const minute = Number(matched[5]);

const now = new Date();
const nowYear = now.getFullYear();
const nowMonth = now.getMonth() + 1;
const nowDay = now.getDate();
const nowHour = now.getHours();
const nowMinute = now.getMinutes();

const targetDate = new Date(value);
const diffMs = now - targetDate;
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);

const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
const dateStr = `${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const fullDateStr = `${matched[1]}-${dateStr}`;

// < 1分钟
if (diffMinutes < 1) {
return t('time.relative.justNow');
}
// 1-59分钟
if (diffMinutes < 60) {
return t('time.relative.minutesAgo', { n: diffMinutes });
}
// 1-23小时
if (diffHours < 24) {
return t('time.relative.hoursAgo', { n: diffHours });
}

// 判断是否今天/昨天
const targetMs = targetDate.getTime();
const nowMs = now.getTime();
const todayStart = new Date(nowYear, nowMonth - 1, nowDay).getTime();
const yesterdayStart = todayStart - 86400000;

if (targetMs >= todayStart) {
return t('time.relative.today', { time: timeStr });
}
if (targetMs >= yesterdayStart) {
return t('time.relative.yesterday', { time: timeStr });
}

// 今年或跨年
if (year === nowYear) {
return t('time.relative.thisYear', { date: dateStr, time: timeStr });
}
return t('time.relative.crossYear', { date: fullDateStr, time: timeStr });
}

function normalizeUsageRange(range) {
Expand All @@ -206,7 +262,7 @@ function formatUtcDayKey(value) {
return `${stamp.getUTCFullYear()}-${String(stamp.getUTCMonth() + 1).padStart(2, '0')}-${String(stamp.getUTCDate()).padStart(2, '0')}`;
}

export function buildUsageHeatmap(sessions = [], options = {}) {
export function buildUsageHeatmap(sessions = [], options = {}, t = null) {
const list = Array.isArray(sessions) ? sessions : [];
const normalized = [];
for (const session of list) {
Expand Down Expand Up @@ -399,7 +455,7 @@ function buildUsageBuckets(normalizedSessions, options = {}) {
return { range, buckets };
}

export function buildUsageChartGroups(sessions = [], options = {}) {
export function buildUsageChartGroups(sessions = [], options = {}, t = null) {
const list = Array.isArray(sessions) ? sessions : [];
const normalizedSessions = [];
for (const [sessionIndex, session] of list.entries()) {
Expand Down Expand Up @@ -560,7 +616,7 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
contextWindow: sessionContextWindow,
updatedAt: session.updatedAt || '',
updatedAtMs,
updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || ''),
updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || '', t),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
hasExactMessageCount: session.__messageCountExact === true
};
recentSessions.push(sessionEntry);
Expand All @@ -586,7 +642,7 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
path: pathValue,
count: meta.count,
messageTotal: meta.messageTotal,
updatedAtLabel: meta.updatedAtMs ? formatSessionTimelineTimestamp(new Date(meta.updatedAtMs).toISOString()) : ''
updatedAtLabel: meta.updatedAtMs ? formatSessionTimelineTimestamp(new Date(meta.updatedAtMs).toISOString(), t) : ''
}));

const usedModels = [...modelMap.entries()]
Expand Down Expand Up @@ -698,7 +754,7 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
};
}

export function buildSessionTimelineNodes(messages = [], options = {}) {
export function buildSessionTimelineNodes(messages = [], options = {}, t = null) {
const list = Array.isArray(messages) ? messages : [];
const getKey = typeof options.getKey === 'function'
? options.getKey
Expand All @@ -713,7 +769,7 @@ export function buildSessionTimelineNodes(messages = [], options = {}) {
const role = normalizeSessionMessageRole(message && (message.normalizedRole || message.role));
const roleMeta = toRoleMeta(role);
const key = String(getKey(message, index) || `msg-${index}`);
const displayTime = formatSessionTimelineTimestamp(message && message.timestamp ? message.timestamp : '');
const displayTime = formatSessionTimelineTimestamp(message && message.timestamp ? message.timestamp : '', t);
const title = displayTime
? `#${index + 1} · ${roleMeta.roleLabel} · ${displayTime}`
: `#${index + 1} · ${roleMeta.roleLabel}`;
Expand Down Expand Up @@ -757,8 +813,8 @@ export function buildSessionTimelineNodes(messages = [], options = {}) {
}
const roleValue = roleSet.size === 1 ? Array.from(roleSet)[0] : 'mixed';
const roleMeta = toRoleMeta(roleValue);
const firstTime = formatSessionTimelineTimestamp(list[start] && list[start].timestamp ? list[start].timestamp : '');
const lastTime = formatSessionTimelineTimestamp(list[end] && list[end].timestamp ? list[end].timestamp : '');
const firstTime = formatSessionTimelineTimestamp(list[start] && list[start].timestamp ? list[start].timestamp : '', t);
const lastTime = formatSessionTimelineTimestamp(list[end] && list[end].timestamp ? list[end].timestamp : '', t);
let displayTime = '';
if (firstTime && lastTime) {
displayTime = firstTime === lastTime ? firstTime : `${firstTime} ~ ${lastTime}`;
Expand Down
6 changes: 3 additions & 3 deletions web-ui/modules/app.computed.session.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export function createSessionComputed() {
}
return buildSessionTimelineNodes(this.activeSessionVisibleMessages, {
getKey: (message, index) => this.getRecordRenderKey(message, index)
});
}, this.t);
},
sessionTimelineNodeKeyMap() {
const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : [];
Expand Down Expand Up @@ -274,13 +274,13 @@ export function createSessionComputed() {
sessionUsageCharts() {
return buildUsageChartGroups(this.sessionsUsageList, {
range: this.sessionsUsageTimeRange
});
}, this.t);
},
sessionUsageHeatmap() {
const sessions = this.sessionUsageCharts && Array.isArray(this.sessionUsageCharts.filteredSessions)
? this.sessionUsageCharts.filteredSessions
: this.sessionsUsageList;
const heatmap = buildUsageHeatmap(sessions, { range: this.sessionsUsageTimeRange });
const heatmap = buildUsageHeatmap(sessions, { range: this.sessionsUsageTimeRange }, this.t);
const t = typeof this.t === 'function' ? this.t : null;
const lang = typeof this.lang === 'string' ? this.lang.trim().toLowerCase() : '';
const weekdayAxis = lang === 'en'
Expand Down
30 changes: 30 additions & 0 deletions web-ui/modules/i18n.dict.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,16 @@ const DICT = Object.freeze({
'sessions.loadingList': '会话加载中...',
'sessions.empty': '暂无可用会话记录',
'sessions.unknownTime': '未知时间',

// 相对时间
'time.relative.justNow': '刚刚',
'time.relative.minutesAgo': '{n}分钟前',
'time.relative.hoursAgo': '{n}小时前',
'time.relative.today': '今天 {time}',
'time.relative.yesterday': '昨天 {time}',
'time.relative.thisYear': '{date} {time}',
'time.relative.crossYear': '{date} {time}',

'sessions.query.placeholder.enabled': '关键词检索(支持 Codex/Claude/Gemini/CodeBuddy,例:claude code)',
'sessions.query.placeholder.disabled': '当前来源暂不支持关键词检索',
'sessions.pin': '置顶',
Expand Down Expand Up @@ -1602,6 +1612,16 @@ const DICT = Object.freeze({
'sessions.loadingList': 'セッション一覧を読み込み中...',
'sessions.empty': 'セッションがありません',
'sessions.unknownTime': '不明な時間',

// 相对時間
'time.relative.justNow': 'たった今',
'time.relative.minutesAgo': '{n}分前',
'time.relative.hoursAgo': '{n}時間前',
'time.relative.today': '今日 {time}',
'time.relative.yesterday': '昨日 {time}',
'time.relative.thisYear': '{date} {time}',
'time.relative.crossYear': '{date} {time}',

'sessions.query.placeholder.enabled': 'セッションを検索...',
'sessions.query.placeholder.disabled': '現在のソースでは検索は利用できません',
'sessions.pin': 'ピン留め',
Expand Down Expand Up @@ -2658,6 +2678,16 @@ const DICT = Object.freeze({
'sessions.loadingList': 'Loading sessions...',
'sessions.empty': 'No sessions found',
'sessions.unknownTime': 'unknown time',

// Relative time
'time.relative.justNow': 'Just now',
'time.relative.minutesAgo': '{n}m ago',
'time.relative.hoursAgo': '{n}h ago',
'time.relative.today': 'Today {time}',
'time.relative.yesterday': 'Yesterday {time}',
'time.relative.thisYear': '{date} {time}',
'time.relative.crossYear': '{date} {time}',

'sessions.query.placeholder.enabled': 'Search keywords (Codex/Claude/Gemini/CodeBuddy, e.g. claude code)',
'sessions.query.placeholder.disabled': 'Keyword search is not available for this source',
'sessions.pin': 'Pin',
Expand Down
5 changes: 2 additions & 3 deletions web-ui/partials/index/layout-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,13 @@

<div :class="['app-shell', { standalone: sessionStandalone }]">
<aside class="side-rail" v-if="!sessionStandalone">
<div class="brand-block">
<div class="brand-block" @mouseenter="brandHovered = true" @mouseleave="brandHovered = false">
<div class="brand-head">
<img class="brand-logo" src="/res/logo-pack.webp" alt="Codex Mate logo">
<div class="brand-copy">
<div class="brand-kicker">Codex Mate <span v-if="appVersion" class="brand-version">v{{ appVersion }}</span></div>
<div class="brand-kicker">Codex Mate<transition name="brand-version-fade"><span v-if="appVersion && brandHovered" class="brand-version"> v{{ appVersion }}</span></transition></div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
</div>
<div class="brand-subtitle">{{ t('brand.subtitle.localConfigSessionsWorkspace') }}</div>
</div>

<div class="side-rail-nav">
Expand Down
4 changes: 2 additions & 2 deletions web-ui/partials/index/panel-sessions.html
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
</div>
<div class="session-item-meta">
<span class="session-source" :data-source="session.source">{{ session.sourceLabel }}</span>
<span class="session-item-time">{{ session.updatedAt || t('sessions.unknownTime') }}</span>
<span class="session-item-time">{{ session.updatedAtLabel || session.updatedAt || t('sessions.unknownTime') }}</span>
<span v-if="getSessionHotLabel(session)" class="session-item-hot">{{ getSessionHotLabel(session) }}</span>
<span v-if="session.cwd" class="session-item-cwd session-item-sub">{{ session.cwd }}</span>
<div v-if="session.match && session.match.snippets && session.match.snippets.length" class="session-match-snippets">
Expand All @@ -212,7 +212,7 @@
<div class="session-preview-title">{{ activeSession.title || activeSession.sessionId }}</div>
<div class="session-preview-meta">
<span class="session-preview-meta-item session-source" :data-source="activeSession.source">{{ activeSession.sourceLabel }}</span>
<span class="session-preview-meta-item">{{ activeSession.updatedAt || t('sessions.unknownTime') }}</span>
<span class="session-preview-meta-item">{{ activeSession.updatedAtLabel || activeSession.updatedAt || t('sessions.unknownTime') }}</span>
</div>
<div class="session-preview-meta" v-if="activeSession.cwd">
<span class="session-preview-meta-item">{{ activeSession.cwd }}</span>
Expand Down
Loading
Loading