codexmate 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/cli.js +74 -61
  2. package/package.json +2 -1
  3. package/web-ui/app.js +33 -2
  4. package/web-ui/index.html +1 -1
  5. package/web-ui/logic.sessions.mjs +6 -5
  6. package/web-ui/modules/app.computed.dashboard.mjs +4 -0
  7. package/web-ui/modules/app.computed.session.mjs +147 -6
  8. package/web-ui/modules/app.methods.claude-config.mjs +4 -0
  9. package/web-ui/modules/app.methods.navigation.mjs +32 -16
  10. package/web-ui/modules/app.methods.session-browser.mjs +7 -0
  11. package/web-ui/modules/app.methods.session-trash.mjs +30 -0
  12. package/web-ui/modules/i18n.dict.mjs +5 -0
  13. package/web-ui/modules/sessions-filters-url.mjs +65 -12
  14. package/web-ui/modules/skills.methods.mjs +31 -0
  15. package/web-ui/partials/index/layout-header.html +17 -12
  16. package/web-ui/partials/index/panel-config-claude.html +5 -3
  17. package/web-ui/partials/index/panel-config-codex.html +1 -1
  18. package/web-ui/partials/index/panel-market.html +76 -149
  19. package/web-ui/partials/index/panel-sessions.html +2 -2
  20. package/web-ui/partials/index/panel-settings.html +4 -2
  21. package/web-ui/partials/index/panel-usage.html +115 -68
  22. package/web-ui/res/vue.runtime.global.prod.js +7 -0
  23. package/web-ui/res/web-ui-render.precompiled.js +7274 -0
  24. package/web-ui/session-helpers.mjs +15 -4
  25. package/web-ui/source-bundle.cjs +73 -1
  26. package/web-ui/styles/base-theme.css +10 -0
  27. package/web-ui/styles/layout-shell.css +66 -27
  28. package/web-ui/styles/navigation-panels.css +8 -0
  29. package/web-ui/styles/responsive.css +50 -9
  30. package/web-ui/styles/sessions-usage.css +336 -319
  31. package/web-ui/styles/skills-market.css +294 -0
  32. package/web-ui/styles/titles-cards.css +14 -0
package/cli.js CHANGED
@@ -4879,27 +4879,29 @@ function listClaudeSessions(limit, options = {}) {
4879
4879
  }
4880
4880
  }
4881
4881
 
4882
- if (sessions.length === 0) {
4883
- const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4884
- returnCount: scanCount,
4885
- maxFilesScanned,
4886
- ignoreSubPath: `${path.sep}subagents${path.sep}`
4882
+ // 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
4883
+ const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
4884
+ const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4885
+ returnCount: scanCount,
4886
+ maxFilesScanned,
4887
+ ignoreSubPath: `${path.sep}subagents${path.sep}`
4888
+ });
4889
+ for (const filePath of fallbackFiles) {
4890
+ if (seenFilePaths.has(filePath)) continue;
4891
+ const summary = parseClaudeSessionSummary(filePath, {
4892
+ summaryReadBytes,
4893
+ titleReadBytes
4887
4894
  });
4888
- for (const filePath of fallbackFiles) {
4889
- const summary = parseClaudeSessionSummary(filePath, {
4890
- summaryReadBytes,
4891
- titleReadBytes
4892
- });
4893
- if (summary) {
4894
- sessions.push(attachSessionNativeStatus({
4895
- ...summary,
4896
- derived: isDerivedSessionFile(filePath)
4897
- }));
4898
- }
4895
+ if (summary) {
4896
+ sessions.push(attachSessionNativeStatus({
4897
+ ...summary,
4898
+ derived: isDerivedSessionFile(filePath)
4899
+ }));
4900
+ seenFilePaths.add(filePath);
4901
+ }
4899
4902
 
4900
- if (sessions.length >= targetCount) {
4901
- break;
4902
- }
4903
+ if (sessions.length >= targetCount) {
4904
+ break;
4903
4905
  }
4904
4906
  }
4905
4907
 
@@ -10058,9 +10060,10 @@ function watchPathsForRestart(targets, onChange) {
10058
10060
  }
10059
10061
  // #endregion watchPathsForRestart
10060
10062
 
10061
- function writeJsonResponse(res, statusCode, payload) {
10063
+ function writeJsonResponse(res, statusCode, payload, headers = {}) {
10062
10064
  const body = JSON.stringify(payload, null, 2);
10063
10065
  res.writeHead(statusCode, {
10066
+ ...headers,
10064
10067
  'Content-Type': 'application/json; charset=utf-8',
10065
10068
  'Content-Length': Buffer.byteLength(body, 'utf-8')
10066
10069
  });
@@ -10620,7 +10623,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10620
10623
  const securityHeaders = {
10621
10624
  'X-Content-Type-Options': 'nosniff',
10622
10625
  'X-Frame-Options': 'DENY',
10623
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10626
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10624
10627
  };
10625
10628
  const origWriteHead = res.writeHead.bind(res);
10626
10629
  res.writeHead = function (statusCode, headers) {
@@ -10649,34 +10652,15 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10649
10652
  || requestPath.startsWith('/download/')
10650
10653
  ) {
10651
10654
  const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
10652
- const isLoopback = !remoteAddr
10653
- || remoteAddr === '127.0.0.1'
10654
- || remoteAddr === '::1'
10655
- || remoteAddr === '::ffff:127.0.0.1';
10655
+ const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr);
10656
10656
  if (!isLoopback) {
10657
10657
  const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath;
10658
10658
  if (!checkRateLimit(rateLimitKey)) {
10659
- res.writeHead(429, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '60' });
10660
- res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
10659
+ writeJsonResponse(res, 429, { error: 'Rate limit exceeded' }, { 'Retry-After': '60' });
10661
10660
  return;
10662
10661
  }
10663
- const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
10664
- ? process.env.CODEXMATE_HTTP_TOKEN.trim()
10665
- : '';
10666
- if (!expected) {
10667
- sendJson(403, {
10668
- error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN or use --host 127.0.0.1)'
10669
- });
10670
- return;
10671
- }
10672
- const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
10673
- const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
10674
- const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
10675
- const actual = match && match[1]
10676
- ? match[1].trim()
10677
- : (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
10678
- if (!actual || !safeTimingEqual(actual, expected)) {
10679
- sendJson(401, { error: 'Unauthorized' });
10662
+ const auth = assertRequestAuthorized(req, res);
10663
+ if (!auth.ok) {
10680
10664
  return;
10681
10665
  }
10682
10666
  }
@@ -11408,15 +11392,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11408
11392
  res.end(errorBody, 'utf-8');
11409
11393
  }
11410
11394
  });
11411
- } else if (requestPath === '/web-ui') {
11412
- try {
11413
- const html = readBundledWebUiHtml(htmlPath);
11414
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11415
- res.end(html);
11416
- } catch (error) {
11417
- writeWebUiAssetError(res, requestPath, error);
11418
- }
11395
+ } else if (requestPath === '/web-ui/index.html') {
11396
+ const rawUrl = typeof req.url === 'string' ? req.url : '';
11397
+ const queryIndex = rawUrl.indexOf('?');
11398
+ const query = queryIndex >= 0 ? rawUrl.slice(queryIndex) : '';
11399
+ res.writeHead(302, {
11400
+ 'Location': `/${query}`,
11401
+ 'Content-Type': 'text/plain; charset=utf-8',
11402
+ 'Cache-Control': 'no-store, max-age=0'
11403
+ });
11404
+ res.end('Found');
11419
11405
  } else if (requestPath.startsWith('/web-ui/')) {
11406
+ // Skip the /web-ui/ directory itself, which is handled above
11420
11407
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
11421
11408
  const filePath = path.join(__dirname, normalized);
11422
11409
  if (!isPathInside(filePath, webDir)) {
@@ -11425,11 +11412,22 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11425
11412
  return;
11426
11413
  }
11427
11414
  const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/');
11415
+
11416
+ // Empty relativePath means direct /web-ui/ access - return 404
11417
+ if (relativePath === '' || relativePath === 'index.html') {
11418
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11419
+ res.end('Not Found');
11420
+ return;
11421
+ }
11422
+
11428
11423
  const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath);
11429
11424
  if (dynamicAsset) {
11430
11425
  try {
11431
11426
  const assetBody = dynamicAsset.reader(filePath);
11432
- res.writeHead(200, { 'Content-Type': dynamicAsset.mime });
11427
+ res.writeHead(200, {
11428
+ 'Content-Type': dynamicAsset.mime,
11429
+ 'Cache-Control': 'no-store, max-age=0'
11430
+ });
11433
11431
  res.end(assetBody, 'utf-8');
11434
11432
  } catch (error) {
11435
11433
  writeWebUiAssetError(res, requestPath, error);
@@ -11456,7 +11454,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11456
11454
  : ext === '.json'
11457
11455
  ? 'application/json; charset=utf-8'
11458
11456
  : 'application/octet-stream';
11459
- res.writeHead(200, { 'Content-Type': mime });
11457
+ res.writeHead(200, {
11458
+ 'Content-Type': mime,
11459
+ 'Cache-Control': 'no-store, max-age=0'
11460
+ });
11460
11461
  fs.createReadStream(filePath).pipe(res);
11461
11462
  } else if (requestPath.startsWith('/download/')) {
11462
11463
  const fileName = requestPath.slice('/download/'.length);
@@ -11517,15 +11518,27 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11517
11518
  : ext === '.json'
11518
11519
  ? 'application/json; charset=utf-8'
11519
11520
  : 'application/octet-stream';
11520
- res.writeHead(200, { 'Content-Type': mime });
11521
+ res.writeHead(200, {
11522
+ 'Content-Type': mime,
11523
+ 'Cache-Control': 'no-store, max-age=0'
11524
+ });
11521
11525
  fs.createReadStream(filePath).pipe(res);
11522
11526
  } else {
11523
- try {
11524
- const html = readBundledWebUiHtml(htmlPath);
11525
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11526
- res.end(html);
11527
- } catch (error) {
11528
- writeWebUiAssetError(res, requestPath, error);
11527
+ // Only serve HTML for root path; /web-ui returns 404.
11528
+ if (requestPath === '/') {
11529
+ try {
11530
+ const html = readBundledWebUiHtml(htmlPath);
11531
+ res.writeHead(200, {
11532
+ 'Content-Type': 'text/html; charset=utf-8',
11533
+ 'Cache-Control': 'no-store, max-age=0'
11534
+ });
11535
+ res.end(html);
11536
+ } catch (error) {
11537
+ writeWebUiAssetError(res, requestPath, error);
11538
+ }
11539
+ } else {
11540
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11541
+ res.end('Not Found');
11529
11542
  }
11530
11543
  }
11531
11544
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@iarna/toml": "^2.2.5",
49
+ "@vue/compiler-dom": "^3.5.30",
49
50
  "json5": "^2.2.3",
50
51
  "yauzl": "^3.2.1",
51
52
  "zip-lib": "^1.2.1"
package/web-ui/app.js CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  import { createAppComputed } from './modules/app.computed.index.mjs';
8
8
  import { createAppMethods } from './modules/app.methods.index.mjs';
9
9
  import { loadConfigTemplateDiffConfirmEnabledFromStorage } from './modules/config-template-confirm-pref.mjs';
10
+ import { installWebUiUrlCanonicalization } from './modules/sessions-filters-url.mjs';
10
11
 
11
12
  document.addEventListener('DOMContentLoaded', () => {
13
+ installWebUiUrlCanonicalization();
12
14
  if (typeof Vue === 'undefined') {
13
15
  console.error('Vue 库未能在 DOMContentLoaded 触发前加载完成。');
14
16
  const fallbackTarget = document.querySelector('#app') || document.querySelector('[v-cloak]');
@@ -26,9 +28,10 @@ document.addEventListener('DOMContentLoaded', () => {
26
28
 
27
29
  const { createApp } = Vue;
28
30
 
29
- const app = createApp({
31
+ const appOptions = {
30
32
  data() {
31
33
  return {
34
+ brandHovered: false,
32
35
  lang: 'zh',
33
36
  appVersion: '',
34
37
  mainTab: 'dashboard',
@@ -230,6 +233,7 @@ document.addEventListener('DOMContentLoaded', () => {
230
233
  sessionPreviewHeaderEl: null,
231
234
  sessionPreviewHeaderResizeObserver: null,
232
235
  sessionListRenderEnabled: false,
236
+ preserveSessionRenderOnTabLeave: true,
233
237
  sessionListVisibleCount: 0,
234
238
  sessionListInitialBatchSize: 40,
235
239
  sessionListLoadStep: 80,
@@ -420,6 +424,27 @@ document.addEventListener('DOMContentLoaded', () => {
420
424
  },
421
425
 
422
426
  mounted() {
427
+ // URL 规范化:将 /web-ui/* 重定向到根路径 /
428
+ try {
429
+ const pathname = window.location.pathname;
430
+ if (pathname === '/web-ui' || pathname === '/web-ui/' || pathname === '/web-ui/index.html') {
431
+ const url = new URL(window.location.href);
432
+ url.pathname = '/';
433
+ // 移除查询参数和 hash,保持 URL 纯净
434
+ url.search = '';
435
+ url.hash = '';
436
+ window.location.replace(url.toString());
437
+ return;
438
+ }
439
+ // 清理任何查询参数和 hash,保持 URL 为 /
440
+ if (window.location.search || window.location.hash) {
441
+ const url = new URL(window.location.href);
442
+ url.search = '';
443
+ url.hash = '';
444
+ window.history.replaceState(null, '', url.toString());
445
+ }
446
+ } catch (_) {}
447
+
423
448
  if (typeof this.initI18n === 'function') {
424
449
  this.initI18n();
425
450
  }
@@ -648,7 +673,13 @@ document.addEventListener('DOMContentLoaded', () => {
648
673
 
649
674
  computed: createAppComputed(),
650
675
  methods: createAppMethods()
651
- });
676
+ };
677
+
678
+ if (typeof window.__CODEXMATE_WEB_UI_RENDER__ === 'function') {
679
+ appOptions.render = window.__CODEXMATE_WEB_UI_RENDER__;
680
+ }
681
+
682
+ const app = createApp(appOptions);
652
683
 
653
684
  app.mount('#app');
654
685
  });
package/web-ui/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <title>Codex Mate</title>
7
7
  <link rel="icon" type="image/webp" href="/res/logo-pack.webp">
8
8
  <link rel="apple-touch-icon" href="/res/logo-pack.webp">
9
- <script src="/res/vue.global.prod.js"></script>
9
+ <script src="/res/vue.runtime.global.prod.js"></script>
10
10
  <script src="/res/json5.min.js"></script>
11
11
  <link rel="stylesheet" href="/web-ui/styles.css">
12
12
  </head>
@@ -180,12 +180,9 @@ export function formatSessionTimelineTimestamp(timestamp) {
180
180
  if (!value) return '';
181
181
 
182
182
  const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/);
183
- if (matched) {
184
- const second = matched[6] || '00';
185
- return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`;
186
- }
183
+ if (!matched) return value;
187
184
 
188
- return value;
185
+ return `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}`;
189
186
  }
190
187
 
191
188
  function normalizeUsageRange(range) {
@@ -549,11 +546,15 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
549
546
  String(messageCount),
550
547
  String(sessionIndex)
551
548
  ].join(':'),
549
+ sessionId: session.sessionId || '',
550
+ filePath: session.filePath || '',
552
551
  title: normalizedTitle,
553
552
  source,
554
553
  sourceLabel,
555
554
  cwd,
556
555
  messageCount,
556
+ totalTokens: sessionTotalTokens,
557
+ contextWindow: sessionContextWindow,
557
558
  updatedAt: session.updatedAt || '',
558
559
  updatedAtMs,
559
560
  updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || ''),
@@ -92,6 +92,10 @@ export function createDashboardComputed() {
92
92
  return list;
93
93
  },
94
94
 
95
+ isLocalProviderDisabled() {
96
+ return this.configMode === 'codex';
97
+ },
98
+
95
99
  displayProviderUrl() {
96
100
  return (provider) => {
97
101
  if (provider && provider.name === 'local') return '';
@@ -602,11 +602,10 @@ export function createSessionComputed() {
602
602
  const sessions = this.sessionUsageCharts && Array.isArray(this.sessionUsageCharts.filteredSessions)
603
603
  ? this.sessionUsageCharts.filteredSessions
604
604
  : this.sessionsUsageList;
605
- const compareEnabled = this.sessionsUsageCompareEnabled === true && this.sessionsUsageTimeRange !== 'all';
606
605
  const rangeDays = this.sessionsUsageTimeRange === '30d' ? 30 : 7;
607
606
  const dayMs = 24 * 60 * 60 * 1000;
608
607
  const baseMs = Date.parse(`${dayKey}T00:00:00.000Z`);
609
- const prevKey = compareEnabled && Number.isFinite(baseMs)
608
+ const prevKey = Number.isFinite(baseMs)
610
609
  ? new Date(baseMs - (rangeDays * dayMs)).toISOString().slice(0, 10)
611
610
  : '';
612
611
  let sessionCount = 0;
@@ -636,7 +635,7 @@ export function createSessionComputed() {
636
635
  } else if (isPrev) {
637
636
  prevTokenTotal += sessionTokens;
638
637
  }
639
- const model = typeof session.model === 'string' ? session.model.trim() : '';
638
+ const model = typeof session.model === 'string' ? session.model.trim() : '';
640
639
  if (isCurrent && model) {
641
640
  modelMap.set(model, (modelMap.get(model) || 0) + 1);
642
641
  }
@@ -667,20 +666,162 @@ export function createSessionComputed() {
667
666
  }));
668
667
  return {
669
668
  dayKey,
670
- compareEnabled,
671
669
  prevKey,
672
670
  sessionCount,
673
671
  messageCount,
674
672
  tokenTotal,
675
673
  tokenLabel: formatUsageSummaryNumber(tokenTotal),
676
674
  prevTokenTotal,
677
- prevTokenLabel: compareEnabled ? formatUsageSummaryNumber(prevTokenTotal) : '0',
678
- deltaTokenLabel: compareEnabled ? formatSignedUsageSummaryNumber(tokenTotal - prevTokenTotal) : '0',
675
+ prevTokenLabel: prevKey ? formatUsageSummaryNumber(prevTokenTotal) : null,
676
+ deltaTokenLabel: prevKey ? formatSignedUsageSummaryNumber(tokenTotal - prevTokenTotal) : null,
679
677
  topSessions,
680
678
  topModels
681
679
  };
682
680
  },
683
681
 
682
+ usageHeroMainValue() {
683
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
684
+ ? this.sessionUsageCharts.summary
685
+ : null;
686
+ if (!summary) return '0';
687
+ return formatCompactUsageSummaryNumber(summary.totalTokens || 0);
688
+ },
689
+
690
+ usageHeroSubLabel() {
691
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
692
+ ? this.sessionUsageCharts.summary
693
+ : null;
694
+ if (!summary) return '';
695
+ const t = typeof this.t === 'function' ? this.t : null;
696
+ const sessionCount = summary.totalSessions || 0;
697
+ const rangeLabel = this.sessionsUsageTimeRange === '30d' ? '30天' : (this.sessionsUsageTimeRange === 'all' ? '全部' : '7天');
698
+ const rangeText = t ? t('usage.range.' + this.sessionsUsageTimeRange) : rangeLabel;
699
+ return `${formatUsageSummaryNumber(sessionCount)} sessions · ${rangeText}`;
700
+ },
701
+
702
+ usageHeroDelta() {
703
+ const range = this.sessionsUsageTimeRange;
704
+ if (range === 'all') return null;
705
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
706
+ ? this.sessionUsageCharts.summary
707
+ : null;
708
+ if (!summary || !summary.totalTokens) return null;
709
+
710
+ const rangeDays = range === '30d' ? 30 : 7;
711
+ const dayMs = 24 * 60 * 60 * 1000;
712
+ const nowMs = Date.now();
713
+ const prevStartMs = nowMs - (rangeDays * 2 * dayMs);
714
+ const prevEndMs = nowMs - (rangeDays * dayMs);
715
+
716
+ let prevTokens = 0;
717
+ for (const session of (Array.isArray(this.sessionsUsageList) ? this.sessionsUsageList : [])) {
718
+ if (!session || typeof session !== 'object') continue;
719
+ const updatedAtMs = Date.parse(session.updatedAt || '');
720
+ if (!Number.isFinite(updatedAtMs)) continue;
721
+ if (updatedAtMs >= prevStartMs && updatedAtMs < prevEndMs) {
722
+ const sessionTokens = Number.isFinite(Number(session.totalTokens))
723
+ ? Math.max(0, Math.floor(Number(session.totalTokens)))
724
+ : 0;
725
+ prevTokens += sessionTokens;
726
+ }
727
+ }
728
+
729
+ if (prevTokens === 0) return null;
730
+ const currentTokens = summary.totalTokens;
731
+ const delta = currentTokens - prevTokens;
732
+ const deltaPercent = prevTokens > 0 ? Math.round((delta / prevTokens) * 100) : 0;
733
+ const arrow = delta > 0 ? '↑' : (delta < 0 ? '↓' : '–');
734
+ const sign = delta >= 0 ? '+' : '';
735
+ return `${arrow} ${sign}${deltaPercent}%`;
736
+ },
737
+
738
+ usageHeroDeltaClass() {
739
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
740
+ ? this.sessionUsageCharts.summary
741
+ : null;
742
+ if (!summary || !summary.totalTokens) return '';
743
+
744
+ const range = this.sessionsUsageTimeRange;
745
+ if (range === 'all') return '';
746
+
747
+ const rangeDays = range === '30d' ? 30 : 7;
748
+ const dayMs = 24 * 60 * 60 * 1000;
749
+ const nowMs = Date.now();
750
+ const prevStartMs = nowMs - (rangeDays * 2 * dayMs);
751
+ const prevEndMs = nowMs - (rangeDays * dayMs);
752
+
753
+ let prevTokens = 0;
754
+ for (const session of (Array.isArray(this.sessionsUsageList) ? this.sessionsUsageList : [])) {
755
+ if (!session || typeof session !== 'object') continue;
756
+ const updatedAtMs = Date.parse(session.updatedAt || '');
757
+ if (!Number.isFinite(updatedAtMs)) continue;
758
+ if (updatedAtMs >= prevStartMs && updatedAtMs < prevEndMs) {
759
+ const sessionTokens = Number.isFinite(Number(session.totalTokens))
760
+ ? Math.max(0, Math.floor(Number(session.totalTokens)))
761
+ : 0;
762
+ prevTokens += sessionTokens;
763
+ }
764
+ }
765
+
766
+ if (prevTokens === 0) return '';
767
+ const currentTokens = summary.totalTokens;
768
+ return currentTokens >= prevTokens ? 'delta-up' : 'delta-down';
769
+ },
770
+
771
+ sessionsUsageSelectedDay() {
772
+ return this.sessionsUsageSelectedDayKey || '';
773
+ },
774
+
775
+ sessionUsageWave() {
776
+ const daily = this.sessionUsageDaily && typeof this.sessionUsageDaily === 'object'
777
+ ? this.sessionUsageDaily
778
+ : null;
779
+ if (!daily || !Array.isArray(daily.rows) || daily.rows.length === 0) {
780
+ return { points: [], labels: [], linePath: '', areaPath: '', width: 800, maxTokens: 0 };
781
+ }
782
+
783
+ const rows = daily.rows;
784
+ const maxTokens = daily.maxTokens || 1;
785
+ const width = 800;
786
+ const height = 140;
787
+ const padding = { top: 10, bottom: 30, left: 0, right: 0 };
788
+ const chartWidth = width - padding.left - padding.right;
789
+ const chartHeight = height - padding.top - padding.bottom;
790
+
791
+ const points = rows.map((row, index) => {
792
+ const x = padding.left + (index / (rows.length - 1 || 1)) * chartWidth;
793
+ const normalizedValue = maxTokens > 0 ? (row.tokenTotal / maxTokens) : 0;
794
+ const y = padding.top + chartHeight - (normalizedValue * chartHeight);
795
+ return { x, y, key: row.key, value: row.tokenTotal, label: row.label };
796
+ });
797
+
798
+ const linePath = points.length > 1
799
+ ? `M ${points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ')}`
800
+ : '';
801
+
802
+ const areaPath = points.length > 1
803
+ ? `${linePath} L ${points[points.length - 1].x.toFixed(1)},${(padding.top + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)},${(padding.top + chartHeight).toFixed(1)} Z`
804
+ : '';
805
+
806
+ const selectedKey = this.sessionsUsageSelectedDayKey;
807
+ const selectedPoint = points.find(p => p.key === selectedKey) || points[points.length - 1] || null;
808
+
809
+ return {
810
+ points,
811
+ labels: rows.map((row, index) => ({
812
+ key: row.key,
813
+ text: row.label
814
+ })),
815
+ linePath,
816
+ areaPath,
817
+ width,
818
+ height,
819
+ maxTokens,
820
+ hoverX: selectedPoint ? selectedPoint.x : 0,
821
+ hoverY: selectedPoint ? selectedPoint.y : 0
822
+ };
823
+ },
824
+
684
825
  visibleSessionTrashItems() {
685
826
  const items = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : [];
686
827
  const visibleCount = Number(this.sessionTrashVisibleCount);
@@ -266,6 +266,10 @@ export function createClaudeConfigMethods(options = {}) {
266
266
  return this.claudeLocalBridgeCandidateProviders().some(p => p.hasKey);
267
267
  },
268
268
 
269
+ isClaudeLocalBridgeDisabled() {
270
+ return this.configMode === 'claude';
271
+ },
272
+
269
273
  async applyClaudeLocalBridge() {
270
274
  this.currentClaudeConfig = 'claude-local';
271
275
  try { localStorage.setItem('currentClaudeConfig', 'claude-local'); } catch (_) {}
@@ -56,6 +56,32 @@
56
56
  return null;
57
57
  }
58
58
  };
59
+
60
+ const canonicalizeWebUiRuntimeUrl = () => {
61
+ if (typeof window === 'undefined' || !window.location) return;
62
+ try {
63
+ const url = new URL(window.location.href);
64
+ if (url.pathname === '/session') return;
65
+ let pathname = url.pathname;
66
+ let previousPathname = '';
67
+ do {
68
+ previousPathname = pathname;
69
+ pathname = pathname.replace(/\/+web-ui\/+web-ui\/+/, '/web-ui/');
70
+ } while (pathname !== previousPathname);
71
+ if (pathname === '/web-ui' || pathname === '/web-ui/' || pathname === '/web-ui/index.html') {
72
+ pathname = '/';
73
+ }
74
+ url.pathname = pathname;
75
+ url.search = '';
76
+ url.hash = '';
77
+ const nextUrl = url.toString();
78
+ if (nextUrl === window.location.href) return;
79
+ if (window.history && typeof window.history.replaceState === 'function') {
80
+ window.history.replaceState(null, '', nextUrl);
81
+ }
82
+ } catch (_) {}
83
+ };
84
+
59
85
  const persistNavState = (vm, overrides = null) => {
60
86
  if (!vm || vm.__navStateRestoring) return;
61
87
  if (typeof localStorage === 'undefined') return;
@@ -138,6 +164,7 @@
138
164
  const normalizedMode = typeof mode === 'string'
139
165
  ? mode.trim().toLowerCase()
140
166
  : '';
167
+ canonicalizeWebUiRuntimeUrl();
141
168
  this.cancelTouchNavIntentReset();
142
169
  if (typeof this.ensureMainTabSwitchState === 'function') {
143
170
  this.ensureMainTabSwitchState().pendingConfigMode = '';
@@ -412,6 +439,7 @@
412
439
  : '';
413
440
  const targetTab = normalizedTab || tab;
414
441
  if (!targetTab) return;
442
+ canonicalizeWebUiRuntimeUrl();
415
443
  if (targetTab === 'orchestration' && this.taskOrchestrationTabEnabled !== true) {
416
444
  return this.switchMainTab('config');
417
445
  }
@@ -419,20 +447,7 @@
419
447
  mainTab: targetTab,
420
448
  configMode: targetTab === 'config' ? this.configMode : this.configMode
421
449
  });
422
- if (targetTab !== 'sessions') {
423
- try {
424
- const url = new URL(window.location.href);
425
- if (url.pathname !== '/session') {
426
- url.searchParams.delete('s_source');
427
- url.searchParams.delete('s_path');
428
- url.searchParams.delete('s_query');
429
- url.searchParams.delete('s_role');
430
- url.searchParams.delete('s_time');
431
- url.searchParams.delete('tab');
432
- window.history.replaceState(null, '', url.toString());
433
- }
434
- } catch (_) {}
435
- }
450
+ // URL 保持静态,不写入任何状态
436
451
  this.cancelTouchNavIntentReset();
437
452
  if (targetTab === 'sessions') {
438
453
  this.cancelScheduledSessionTabDeferredTeardown();
@@ -474,9 +489,10 @@
474
489
  return;
475
490
  }
476
491
  const isLeavingSessions = previousTab === 'sessions' && targetTab !== 'sessions';
477
- const shouldDeferApply = isLeavingSessions;
492
+ const shouldPreserveSessionRender = isLeavingSessions && this.preserveSessionRenderOnTabLeave === true;
493
+ const shouldDeferApply = isLeavingSessions && !shouldPreserveSessionRender;
478
494
  if (isLeavingSessions && !this.isSessionPanelFastHidden()) {
479
- this.setSessionPanelFastHidden(true);
495
+ this.setSessionPanelFastHidden(!shouldPreserveSessionRender);
480
496
  }
481
497
  if (shouldDeferApply && typeof this.suspendSessionTabRender === 'function') {
482
498
  this.suspendSessionTabRender();
@@ -264,6 +264,13 @@ export function createSessionBrowserMethods(options = {}) {
264
264
  });
265
265
  if (urlState) {
266
266
  applySessionsFilterUrlState(this, urlState);
267
+ // 清理 URL,保持静态
268
+ try {
269
+ const url = new URL(window.location.href);
270
+ url.search = '';
271
+ url.hash = '';
272
+ window.history.replaceState(null, '', url.toString());
273
+ } catch (_) {}
267
274
  try {
268
275
  const sortCache = localStorage.getItem('codexmateSessionSortMode');
269
276
  this.sessionSortMode = normalizeSortMode(sortCache);