codexmate 0.0.13 → 0.0.14

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.
package/web-ui/app.js CHANGED
@@ -7,7 +7,12 @@
7
7
  formatLatency,
8
8
  buildSpeedTestIssue,
9
9
  isSessionQueryEnabled,
10
- buildSessionListParams
10
+ buildSessionListParams,
11
+ normalizeSessionSource,
12
+ normalizeSessionPathFilter,
13
+ buildSessionFilterCacheState,
14
+ buildSessionTimelineNodes,
15
+ normalizeSessionMessageRole
11
16
  } from './logic.mjs';
12
17
 
13
18
  document.addEventListener('DOMContentLoaded', () => {
@@ -81,6 +86,7 @@
81
86
  showOpenclawConfigModal: false,
82
87
  showConfigTemplateModal: false,
83
88
  showAgentsModal: false,
89
+ showSkillsModal: false,
84
90
  showInstallModal: false,
85
91
  configTemplateContent: '',
86
92
  configTemplateApplying: false,
@@ -94,6 +100,17 @@
94
100
  agentsContext: 'codex',
95
101
  agentsModalTitle: 'AGENTS.md 编辑器',
96
102
  agentsModalHint: '保存后会写入目标 AGENTS.md(与 config.toml 同级)。',
103
+ skillsRootPath: '',
104
+ skillsList: [],
105
+ skillsSelectedNames: [],
106
+ skillsLoading: false,
107
+ skillsDeleting: false,
108
+ skillsKeyword: '',
109
+ skillsStatusFilter: 'all',
110
+ skillsImportList: [],
111
+ skillsImportSelectedKeys: [],
112
+ skillsScanningImports: false,
113
+ skillsImporting: false,
97
114
  sessionsList: [],
98
115
  sessionsLoading: false,
99
116
  sessionFilterSource: 'all',
@@ -124,6 +141,13 @@
124
141
  activeSessionDetailClipped: false,
125
142
  sessionDetailLoading: false,
126
143
  sessionDetailRequestSeq: 0,
144
+ sessionTimelineActiveKey: '',
145
+ sessionTimelineRafId: 0,
146
+ sessionMessageRefMap: Object.create(null),
147
+ sessionPreviewScrollEl: null,
148
+ sessionPreviewContainerEl: null,
149
+ sessionPreviewHeaderEl: null,
150
+ sessionPreviewHeaderResizeObserver: null,
127
151
  sessionStandalone: false,
128
152
  sessionStandaloneError: '',
129
153
  sessionStandaloneText: '',
@@ -269,6 +293,8 @@
269
293
  } else if (savedSessionYolo === '1' || savedSessionYolo === 'true') {
270
294
  this.sessionResumeWithYolo = true;
271
295
  }
296
+ this.restoreSessionFilterCache();
297
+ window.addEventListener('resize', this.onWindowResize);
272
298
  const savedConfigs = localStorage.getItem('claudeConfigs');
273
299
  if (savedConfigs) {
274
300
  try {
@@ -304,11 +330,31 @@
304
330
  }
305
331
  this.loadAll();
306
332
  },
333
+ beforeUnmount() {
334
+ this.cancelSessionTimelineSync();
335
+ this.disconnectSessionPreviewHeaderResizeObserver();
336
+ window.removeEventListener('resize', this.onWindowResize);
337
+ this.sessionPreviewScrollEl = null;
338
+ this.sessionPreviewContainerEl = null;
339
+ this.sessionPreviewHeaderEl = null;
340
+ this.sessionMessageRefMap = Object.create(null);
341
+ },
307
342
 
308
343
  computed: {
309
344
  isSessionQueryEnabled() {
310
345
  return isSessionQueryEnabled(this.sessionFilterSource);
311
346
  },
347
+ sessionTimelineNodes() {
348
+ return buildSessionTimelineNodes(this.activeSessionMessages, {
349
+ getKey: (message, index) => this.getRecordRenderKey(message, index)
350
+ });
351
+ },
352
+ sessionTimelineActiveTitle() {
353
+ if (!this.sessionTimelineActiveKey) return '';
354
+ const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : [];
355
+ const matched = nodes.find(node => node.key === this.sessionTimelineActiveKey);
356
+ return matched ? matched.title : '';
357
+ },
312
358
  sessionQueryPlaceholder() {
313
359
  if (this.isSessionQueryEnabled) {
314
360
  return '关键词检索(支持 Codex/Claude,例:claude code)';
@@ -354,6 +400,84 @@
354
400
  installRegistryPreview() {
355
401
  return this.resolveInstallRegistryUrl(this.installRegistryPreset, this.installRegistryCustom);
356
402
  },
403
+ filteredSkillsList() {
404
+ const list = Array.isArray(this.skillsList) ? this.skillsList : [];
405
+ const keyword = typeof this.skillsKeyword === 'string' ? this.skillsKeyword.trim().toLowerCase() : '';
406
+ const status = typeof this.skillsStatusFilter === 'string' ? this.skillsStatusFilter : 'all';
407
+ return list.filter((item) => {
408
+ const safe = item && typeof item === 'object' ? item : {};
409
+ const hasSkillFile = !!safe.hasSkillFile;
410
+ if (status === 'with-skill-file' && !hasSkillFile) return false;
411
+ if (status === 'missing-skill-file' && hasSkillFile) return false;
412
+ if (!keyword) return true;
413
+ const fields = [
414
+ safe.name,
415
+ safe.displayName,
416
+ safe.description,
417
+ safe.path
418
+ ];
419
+ return fields.some((value) => typeof value === 'string' && value.toLowerCase().includes(keyword));
420
+ });
421
+ },
422
+ skillsSelectableNames() {
423
+ const list = Array.isArray(this.filteredSkillsList) ? this.filteredSkillsList : [];
424
+ return list
425
+ .map((item) => (item && typeof item.name === 'string' ? item.name.trim() : ''))
426
+ .filter(Boolean);
427
+ },
428
+ skillsConfiguredCount() {
429
+ const list = Array.isArray(this.skillsList) ? this.skillsList : [];
430
+ return list.filter((item) => !!(item && item.hasSkillFile)).length;
431
+ },
432
+ skillsMissingSkillFileCount() {
433
+ const list = Array.isArray(this.skillsList) ? this.skillsList : [];
434
+ return list.filter((item) => !(item && item.hasSkillFile)).length;
435
+ },
436
+ skillsFilterDirty() {
437
+ const keyword = typeof this.skillsKeyword === 'string' ? this.skillsKeyword.trim() : '';
438
+ const status = typeof this.skillsStatusFilter === 'string' ? this.skillsStatusFilter : 'all';
439
+ return keyword.length > 0 || status !== 'all';
440
+ },
441
+ skillsSelectedCount() {
442
+ const selected = Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : [];
443
+ return Array.from(new Set(selected.map((item) => String(item || '').trim()).filter(Boolean))).length;
444
+ },
445
+ skillsVisibleSelectedCount() {
446
+ const selectable = this.skillsSelectableNames;
447
+ const selectedSet = new Set(Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : []);
448
+ return selectable.filter((name) => selectedSet.has(name)).length;
449
+ },
450
+ skillsAllSelected() {
451
+ const selectable = this.skillsSelectableNames;
452
+ if (!selectable.length) return false;
453
+ const selectedSet = new Set(Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : []);
454
+ return selectable.every((name) => selectedSet.has(name));
455
+ },
456
+ skillsImportSelectableKeys() {
457
+ const list = Array.isArray(this.skillsImportList) ? this.skillsImportList : [];
458
+ return list
459
+ .map((item) => this.buildSkillImportKey(item))
460
+ .filter(Boolean);
461
+ },
462
+ skillsImportSelectedCount() {
463
+ const selectable = this.skillsImportSelectableKeys;
464
+ const selectedSet = new Set(Array.isArray(this.skillsImportSelectedKeys) ? this.skillsImportSelectedKeys : []);
465
+ return selectable.filter((key) => selectedSet.has(key)).length;
466
+ },
467
+ skillsImportAllSelected() {
468
+ const selectable = this.skillsImportSelectableKeys;
469
+ if (!selectable.length) return false;
470
+ const selectedSet = new Set(Array.isArray(this.skillsImportSelectedKeys) ? this.skillsImportSelectedKeys : []);
471
+ return selectable.every((key) => selectedSet.has(key));
472
+ },
473
+ skillsImportConfiguredCount() {
474
+ const list = Array.isArray(this.skillsImportList) ? this.skillsImportList : [];
475
+ return list.filter((item) => !!(item && item.hasSkillFile)).length;
476
+ },
477
+ skillsImportMissingSkillFileCount() {
478
+ const list = Array.isArray(this.skillsImportList) ? this.skillsImportList : [];
479
+ return list.filter((item) => !(item && item.hasSkillFile)).length;
480
+ },
357
481
  inspectorMainTabLabel() {
358
482
  if (this.mainTab === 'config') return '配置中心';
359
483
  if (this.mainTab === 'sessions') return '会话浏览';
@@ -418,6 +542,7 @@
418
542
  if (this.codexModelsLoading || this.claudeModelsLoading) tasks.push('模型加载');
419
543
  if (this.codexApplying || this.configTemplateApplying || this.openclawApplying) tasks.push('配置应用');
420
544
  if (this.agentsSaving) tasks.push('AGENTS 保存');
545
+ if (this.skillsLoading || this.skillsDeleting || this.skillsScanningImports || this.skillsImporting) tasks.push('Skills 管理');
421
546
  if (this.proxySaving || this.proxyApplying || this.proxyStarting || this.proxyStopping) tasks.push('代理更新');
422
547
  return tasks.length ? tasks.join(' / ') : '空闲';
423
548
  },
@@ -799,6 +924,9 @@
799
924
  this.activeSessionMessages = [];
800
925
  this.activeSessionDetailError = '';
801
926
  this.activeSessionDetailClipped = false;
927
+ this.cancelSessionTimelineSync();
928
+ this.sessionTimelineActiveKey = '';
929
+ this.sessionMessageRefMap = Object.create(null);
802
930
  this.sessionStandaloneError = '';
803
931
  this.sessionStandaloneText = '';
804
932
  this.sessionStandaloneTitle = this.activeSession.title || '会话';
@@ -1167,8 +1295,7 @@
1167
1295
  },
1168
1296
 
1169
1297
  normalizeSessionPathValue(value) {
1170
- if (typeof value !== 'string') return '';
1171
- return value.trim();
1298
+ return normalizeSessionPathFilter(value);
1172
1299
  },
1173
1300
 
1174
1301
  mergeSessionPathOptions(baseList = [], incomingList = []) {
@@ -1277,13 +1404,32 @@
1277
1404
  const value = this.sessionResumeWithYolo ? '1' : '0';
1278
1405
  localStorage.setItem('codexmateSessionResumeYolo', value);
1279
1406
  },
1407
+ restoreSessionFilterCache() {
1408
+ const sourceCache = localStorage.getItem('codexmateSessionFilterSource');
1409
+ const pathCache = localStorage.getItem('codexmateSessionPathFilter');
1410
+ const cached = buildSessionFilterCacheState(sourceCache, pathCache);
1411
+ this.sessionFilterSource = cached.source;
1412
+ this.sessionPathFilter = cached.pathFilter;
1413
+ this.refreshSessionPathOptions(this.sessionFilterSource);
1414
+ },
1415
+ persistSessionFilterCache() {
1416
+ const cached = buildSessionFilterCacheState(this.sessionFilterSource, this.sessionPathFilter);
1417
+ localStorage.setItem('codexmateSessionFilterSource', cached.source);
1418
+ if (cached.pathFilter) {
1419
+ localStorage.setItem('codexmateSessionPathFilter', cached.pathFilter);
1420
+ } else {
1421
+ localStorage.removeItem('codexmateSessionPathFilter');
1422
+ }
1423
+ },
1280
1424
 
1281
1425
  async onSessionSourceChange() {
1282
1426
  this.refreshSessionPathOptions(this.sessionFilterSource);
1427
+ this.persistSessionFilterCache();
1283
1428
  await this.loadSessions();
1284
1429
  },
1285
1430
 
1286
1431
  async onSessionPathFilterChange() {
1432
+ this.persistSessionFilterCache();
1287
1433
  await this.loadSessions();
1288
1434
  },
1289
1435
 
@@ -1297,8 +1443,156 @@
1297
1443
  this.sessionQuery = '';
1298
1444
  this.sessionRoleFilter = 'all';
1299
1445
  this.sessionTimePreset = 'all';
1446
+ this.persistSessionFilterCache();
1300
1447
  await this.onSessionSourceChange();
1301
1448
  },
1449
+ setSessionPreviewContainerRef(el) {
1450
+ this.sessionPreviewContainerEl = el || null;
1451
+ this.updateSessionTimelineOffset();
1452
+ },
1453
+ disconnectSessionPreviewHeaderResizeObserver() {
1454
+ if (!this.sessionPreviewHeaderResizeObserver) return;
1455
+ this.sessionPreviewHeaderResizeObserver.disconnect();
1456
+ this.sessionPreviewHeaderResizeObserver = null;
1457
+ },
1458
+ observeSessionPreviewHeaderResize() {
1459
+ this.disconnectSessionPreviewHeaderResizeObserver();
1460
+ if (!this.sessionPreviewHeaderEl || typeof ResizeObserver !== 'function') return;
1461
+ this.sessionPreviewHeaderResizeObserver = new ResizeObserver(() => {
1462
+ this.updateSessionTimelineOffset();
1463
+ });
1464
+ this.sessionPreviewHeaderResizeObserver.observe(this.sessionPreviewHeaderEl);
1465
+ },
1466
+ setSessionPreviewHeaderRef(el) {
1467
+ this.disconnectSessionPreviewHeaderResizeObserver();
1468
+ this.sessionPreviewHeaderEl = el || null;
1469
+ this.observeSessionPreviewHeaderResize();
1470
+ this.updateSessionTimelineOffset();
1471
+ },
1472
+ setSessionPreviewScrollRef(el) {
1473
+ this.sessionPreviewScrollEl = el || null;
1474
+ if (this.sessionPreviewScrollEl) {
1475
+ this.scheduleSessionTimelineSync();
1476
+ } else {
1477
+ this.cancelSessionTimelineSync();
1478
+ }
1479
+ this.updateSessionTimelineOffset();
1480
+ },
1481
+ updateSessionTimelineOffset() {
1482
+ const container = this.sessionPreviewContainerEl || this.$refs.sessionPreviewContainer;
1483
+ if (!container || !container.style) return;
1484
+ const header = this.sessionPreviewHeaderEl
1485
+ || (this.sessionPreviewScrollEl ? this.sessionPreviewScrollEl.querySelector('.session-preview-header') : null)
1486
+ || container.querySelector('.session-preview-header');
1487
+ const headerHeight = header ? Math.ceil(header.getBoundingClientRect().height) : 0;
1488
+ const offset = headerHeight > 0 ? (headerHeight + 12) : 72;
1489
+ container.style.setProperty('--session-preview-header-offset', `${offset}px`);
1490
+ },
1491
+ bindSessionMessageRef(messageKey, el) {
1492
+ if (!messageKey) return;
1493
+ if (el) {
1494
+ this.sessionMessageRefMap[messageKey] = el;
1495
+ } else {
1496
+ delete this.sessionMessageRefMap[messageKey];
1497
+ }
1498
+ },
1499
+ cancelSessionTimelineSync() {
1500
+ if (!this.sessionTimelineRafId) return;
1501
+ if (typeof cancelAnimationFrame === 'function') {
1502
+ cancelAnimationFrame(this.sessionTimelineRafId);
1503
+ }
1504
+ this.sessionTimelineRafId = 0;
1505
+ },
1506
+ scheduleSessionTimelineSync() {
1507
+ if (this.sessionTimelineRafId) return;
1508
+ if (typeof requestAnimationFrame === 'function') {
1509
+ this.sessionTimelineRafId = requestAnimationFrame(() => {
1510
+ this.sessionTimelineRafId = 0;
1511
+ this.syncSessionTimelineActiveFromScroll();
1512
+ });
1513
+ return;
1514
+ }
1515
+ this.syncSessionTimelineActiveFromScroll();
1516
+ },
1517
+ onSessionPreviewScroll() {
1518
+ this.scheduleSessionTimelineSync();
1519
+ },
1520
+ onWindowResize() {
1521
+ this.updateSessionTimelineOffset();
1522
+ this.scheduleSessionTimelineSync();
1523
+ },
1524
+ syncSessionTimelineActiveFromScroll() {
1525
+ const nodes = Array.isArray(this.sessionTimelineNodes) ? this.sessionTimelineNodes : [];
1526
+ if (!nodes.length) {
1527
+ this.sessionTimelineActiveKey = '';
1528
+ return;
1529
+ }
1530
+ const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll;
1531
+ if (!scrollEl) {
1532
+ this.sessionTimelineActiveKey = nodes[0].key;
1533
+ return;
1534
+ }
1535
+ const scrollRect = scrollEl.getBoundingClientRect();
1536
+ const headerEl = scrollEl.querySelector('.session-preview-header');
1537
+ const headerHeight = headerEl ? headerEl.getBoundingClientRect().height : 0;
1538
+ const anchorLine = scrollRect.top + headerHeight + 8;
1539
+ let activeKey = nodes[0].key;
1540
+ for (const node of nodes) {
1541
+ const messageEl = this.sessionMessageRefMap[node.key];
1542
+ if (!messageEl) continue;
1543
+ const messageRect = messageEl.getBoundingClientRect();
1544
+ if (messageRect.top <= anchorLine) {
1545
+ activeKey = node.key;
1546
+ continue;
1547
+ }
1548
+ break;
1549
+ }
1550
+ this.sessionTimelineActiveKey = activeKey;
1551
+ },
1552
+ jumpToSessionTimelineNode(messageKey) {
1553
+ if (!messageKey) return;
1554
+ const scrollEl = this.sessionPreviewScrollEl || this.$refs.sessionPreviewScroll;
1555
+ if (!scrollEl) return;
1556
+ const messageEl = this.sessionMessageRefMap[messageKey];
1557
+ if (!messageEl) return;
1558
+ const headerEl = scrollEl.querySelector('.session-preview-header');
1559
+ const stickyOffset = headerEl ? (headerEl.offsetHeight + 8) : 8;
1560
+ const scrollRect = scrollEl.getBoundingClientRect();
1561
+ const messageRect = messageEl.getBoundingClientRect();
1562
+ const targetScrollTop = scrollEl.scrollTop + (messageRect.top - scrollRect.top) - stickyOffset;
1563
+ this.sessionTimelineActiveKey = messageKey;
1564
+ if (typeof scrollEl.scrollTo === 'function') {
1565
+ scrollEl.scrollTo({
1566
+ top: Math.max(0, targetScrollTop),
1567
+ behavior: 'smooth'
1568
+ });
1569
+ } else {
1570
+ messageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
1571
+ }
1572
+ },
1573
+
1574
+ normalizeSessionMessage(message) {
1575
+ const fallback = {
1576
+ role: 'assistant',
1577
+ normalizedRole: 'assistant',
1578
+ roleLabel: 'Assistant',
1579
+ text: typeof message === 'string' ? message : '',
1580
+ timestamp: ''
1581
+ };
1582
+ const safeMessage = message && typeof message === 'object' ? message : fallback;
1583
+ const normalizedRole = normalizeSessionMessageRole(
1584
+ safeMessage.normalizedRole || safeMessage.role
1585
+ );
1586
+ const roleLabel = normalizedRole === 'user'
1587
+ ? 'User'
1588
+ : (normalizedRole === 'system' ? 'System' : 'Assistant');
1589
+ return {
1590
+ ...safeMessage,
1591
+ role: normalizedRole,
1592
+ normalizedRole,
1593
+ roleLabel
1594
+ };
1595
+ },
1302
1596
 
1303
1597
  getRecordKey(message) {
1304
1598
  if (!message || !Number.isInteger(message.recordLineIndex) || message.recordLineIndex < 0) {
@@ -1347,6 +1641,9 @@
1347
1641
  this.activeSession = null;
1348
1642
  this.activeSessionMessages = [];
1349
1643
  this.activeSessionDetailClipped = false;
1644
+ this.cancelSessionTimelineSync();
1645
+ this.sessionTimelineActiveKey = '';
1646
+ this.sessionMessageRefMap = Object.create(null);
1350
1647
  } else {
1351
1648
  this.sessionsList = Array.isArray(res.sessions) ? res.sessions : [];
1352
1649
  this.syncSessionPathOptionsForSource(
@@ -1358,10 +1655,19 @@
1358
1655
  this.activeSession = null;
1359
1656
  this.activeSessionMessages = [];
1360
1657
  this.activeSessionDetailClipped = false;
1658
+ this.cancelSessionTimelineSync();
1659
+ this.sessionTimelineActiveKey = '';
1660
+ this.sessionMessageRefMap = Object.create(null);
1361
1661
  } else {
1362
1662
  const oldKey = this.activeSession ? this.getSessionExportKey(this.activeSession) : '';
1363
1663
  const matched = this.sessionsList.find(item => this.getSessionExportKey(item) === oldKey);
1364
1664
  this.activeSession = matched || this.sessionsList[0];
1665
+ this.activeSessionMessages = [];
1666
+ this.activeSessionDetailError = '';
1667
+ this.activeSessionDetailClipped = false;
1668
+ this.cancelSessionTimelineSync();
1669
+ this.sessionTimelineActiveKey = '';
1670
+ this.sessionMessageRefMap = Object.create(null);
1365
1671
  await this.loadActiveSessionDetail();
1366
1672
  }
1367
1673
  void this.loadSessionPathOptions({ source: this.sessionFilterSource });
@@ -1371,6 +1677,9 @@
1371
1677
  this.activeSession = null;
1372
1678
  this.activeSessionMessages = [];
1373
1679
  this.activeSessionDetailClipped = false;
1680
+ this.cancelSessionTimelineSync();
1681
+ this.sessionTimelineActiveKey = '';
1682
+ this.sessionMessageRefMap = Object.create(null);
1374
1683
  this.showMessage('加载会话失败', 'error');
1375
1684
  } finally {
1376
1685
  this.sessionsLoading = false;
@@ -1384,6 +1693,9 @@
1384
1693
  this.activeSessionMessages = [];
1385
1694
  this.activeSessionDetailError = '';
1386
1695
  this.activeSessionDetailClipped = false;
1696
+ this.cancelSessionTimelineSync();
1697
+ this.sessionTimelineActiveKey = '';
1698
+ this.sessionMessageRefMap = Object.create(null);
1387
1699
  await this.loadActiveSessionDetail();
1388
1700
  },
1389
1701
 
@@ -1437,6 +1749,9 @@
1437
1749
  this.activeSessionMessages = [];
1438
1750
  this.activeSessionDetailError = '';
1439
1751
  this.activeSessionDetailClipped = false;
1752
+ this.cancelSessionTimelineSync();
1753
+ this.sessionTimelineActiveKey = '';
1754
+ this.sessionMessageRefMap = Object.create(null);
1440
1755
  return;
1441
1756
  }
1442
1757
 
@@ -1459,10 +1774,14 @@
1459
1774
  this.activeSessionMessages = [];
1460
1775
  this.activeSessionDetailClipped = false;
1461
1776
  this.activeSessionDetailError = res.error;
1777
+ this.cancelSessionTimelineSync();
1778
+ this.sessionTimelineActiveKey = '';
1779
+ this.sessionMessageRefMap = Object.create(null);
1462
1780
  return;
1463
1781
  }
1464
1782
 
1465
- this.activeSessionMessages = Array.isArray(res.messages) ? res.messages : [];
1783
+ const rawMessages = Array.isArray(res.messages) ? res.messages : [];
1784
+ this.activeSessionMessages = rawMessages.map((message) => this.normalizeSessionMessage(message));
1466
1785
  this.activeSessionDetailClipped = !!res.clipped;
1467
1786
  if (this.activeSession) {
1468
1787
  if (res.sourceLabel) {
@@ -1487,6 +1806,10 @@
1487
1806
  if (Number.isFinite(res.totalMessages)) {
1488
1807
  this.syncActiveSessionMessageCount(res.totalMessages);
1489
1808
  }
1809
+ this.$nextTick(() => {
1810
+ this.updateSessionTimelineOffset();
1811
+ this.scheduleSessionTimelineSync();
1812
+ });
1490
1813
  } catch (e) {
1491
1814
  if (requestSeq !== this.sessionDetailRequestSeq) {
1492
1815
  return;
@@ -1494,6 +1817,9 @@
1494
1817
  this.activeSessionMessages = [];
1495
1818
  this.activeSessionDetailClipped = false;
1496
1819
  this.activeSessionDetailError = '加载会话内容失败: ' + e.message;
1820
+ this.cancelSessionTimelineSync();
1821
+ this.sessionTimelineActiveKey = '';
1822
+ this.sessionMessageRefMap = Object.create(null);
1497
1823
  } finally {
1498
1824
  if (requestSeq === this.sessionDetailRequestSeq) {
1499
1825
  this.sessionDetailLoading = false;
@@ -1754,6 +2080,191 @@
1754
2080
  }
1755
2081
  },
1756
2082
 
2083
+ async openSkillsManager() {
2084
+ this.skillsSelectedNames = [];
2085
+ this.skillsKeyword = '';
2086
+ this.skillsStatusFilter = 'all';
2087
+ this.skillsImportList = [];
2088
+ this.skillsImportSelectedKeys = [];
2089
+ this.showSkillsModal = true;
2090
+ await this.refreshSkillsList({ silent: false });
2091
+ },
2092
+
2093
+ closeSkillsModal() {
2094
+ this.showSkillsModal = false;
2095
+ this.skillsSelectedNames = [];
2096
+ this.skillsImportSelectedKeys = [];
2097
+ },
2098
+
2099
+ async refreshSkillsList(options = {}) {
2100
+ this.skillsLoading = true;
2101
+ try {
2102
+ const res = await api('list-codex-skills');
2103
+ if (res.error) {
2104
+ this.showMessage(res.error, 'error');
2105
+ return;
2106
+ }
2107
+ this.skillsRootPath = res.root || '';
2108
+ this.skillsList = Array.isArray(res.items) ? res.items : [];
2109
+ const currentNames = new Set((Array.isArray(this.skillsList) ? this.skillsList : [])
2110
+ .map((item) => (item && typeof item.name === 'string' ? item.name.trim() : ''))
2111
+ .filter(Boolean));
2112
+ this.skillsSelectedNames = (Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : [])
2113
+ .filter((name) => currentNames.has(name));
2114
+ if (!options.silent) {
2115
+ const exists = res.exists !== false;
2116
+ if (!exists) {
2117
+ this.showMessage('skills 目录不存在,已按空列表显示', 'info');
2118
+ }
2119
+ }
2120
+ } catch (e) {
2121
+ this.showMessage('加载 skills 失败', 'error');
2122
+ } finally {
2123
+ this.skillsLoading = false;
2124
+ }
2125
+ },
2126
+
2127
+ resetSkillsFilters() {
2128
+ this.skillsKeyword = '';
2129
+ this.skillsStatusFilter = 'all';
2130
+ },
2131
+
2132
+ toggleAllSkillsSelection() {
2133
+ const selectable = this.skillsSelectableNames;
2134
+ if (this.skillsAllSelected) {
2135
+ const selectedSet = new Set(Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : []);
2136
+ selectable.forEach((name) => selectedSet.delete(name));
2137
+ this.skillsSelectedNames = Array.from(selectedSet);
2138
+ return;
2139
+ }
2140
+ const selectedSet = new Set(Array.isArray(this.skillsSelectedNames) ? this.skillsSelectedNames : []);
2141
+ selectable.forEach((name) => selectedSet.add(name));
2142
+ this.skillsSelectedNames = Array.from(selectedSet);
2143
+ },
2144
+
2145
+ buildSkillImportKey(item) {
2146
+ const safe = item && typeof item === 'object' ? item : {};
2147
+ const sourceApp = typeof safe.sourceApp === 'string' ? safe.sourceApp.trim().toLowerCase() : '';
2148
+ const name = typeof safe.name === 'string' ? safe.name.trim() : '';
2149
+ if (!sourceApp || !name) return '';
2150
+ return `${sourceApp}:${name}`;
2151
+ },
2152
+
2153
+ toggleAllSkillsImportSelection() {
2154
+ const selectable = this.skillsImportSelectableKeys;
2155
+ if (this.skillsImportAllSelected) {
2156
+ this.skillsImportSelectedKeys = [];
2157
+ return;
2158
+ }
2159
+ this.skillsImportSelectedKeys = [...selectable];
2160
+ },
2161
+
2162
+ async scanImportableSkills() {
2163
+ if (this.skillsScanningImports || this.skillsImporting) return;
2164
+ this.skillsScanningImports = true;
2165
+ try {
2166
+ const res = await api('scan-unmanaged-codex-skills');
2167
+ if (res.error) {
2168
+ this.showMessage(res.error, 'error');
2169
+ return;
2170
+ }
2171
+ this.skillsImportList = Array.isArray(res.items) ? res.items : [];
2172
+ const availableKeys = new Set(this.skillsImportSelectableKeys);
2173
+ this.skillsImportSelectedKeys = (Array.isArray(this.skillsImportSelectedKeys) ? this.skillsImportSelectedKeys : [])
2174
+ .filter((key) => availableKeys.has(key));
2175
+ if (this.skillsImportList.length === 0) {
2176
+ this.showMessage('未扫描到可导入 skill', 'info');
2177
+ } else {
2178
+ this.showMessage(`扫描到 ${this.skillsImportList.length} 个可导入 skill`, 'success');
2179
+ }
2180
+ } catch (e) {
2181
+ this.showMessage('扫描可导入 skill 失败', 'error');
2182
+ } finally {
2183
+ this.skillsScanningImports = false;
2184
+ }
2185
+ },
2186
+
2187
+ async importSelectedSkills() {
2188
+ if (this.skillsImporting) return;
2189
+ const selectedSet = new Set(Array.isArray(this.skillsImportSelectedKeys) ? this.skillsImportSelectedKeys : []);
2190
+ const selectedItems = (Array.isArray(this.skillsImportList) ? this.skillsImportList : [])
2191
+ .filter((item) => selectedSet.has(this.buildSkillImportKey(item)))
2192
+ .map((item) => ({
2193
+ name: item.name,
2194
+ sourceApp: item.sourceApp
2195
+ }));
2196
+ if (!selectedItems.length) {
2197
+ this.showMessage('请先选择要导入的 skill', 'error');
2198
+ return;
2199
+ }
2200
+
2201
+ this.skillsImporting = true;
2202
+ try {
2203
+ const res = await api('import-codex-skills', { items: selectedItems });
2204
+ if (res.error) {
2205
+ this.showMessage(res.error, 'error');
2206
+ return;
2207
+ }
2208
+ const importedCount = Array.isArray(res.imported) ? res.imported.length : 0;
2209
+ const failedCount = Array.isArray(res.failed) ? res.failed.length : 0;
2210
+ if (failedCount > 0 && importedCount > 0) {
2211
+ this.showMessage(`已导入 ${importedCount} 个,失败 ${failedCount} 个`, 'error');
2212
+ } else if (failedCount > 0) {
2213
+ const first = res.failed[0] && res.failed[0].error ? res.failed[0].error : '导入失败';
2214
+ this.showMessage(first, 'error');
2215
+ } else {
2216
+ this.showMessage(`已导入 ${importedCount} 个 skill`, 'success');
2217
+ }
2218
+ await this.refreshSkillsList({ silent: true });
2219
+ } catch (e) {
2220
+ this.showMessage('导入 skill 失败', 'error');
2221
+ } finally {
2222
+ this.skillsImporting = false;
2223
+ await this.scanImportableSkills();
2224
+ }
2225
+ },
2226
+
2227
+ async deleteSelectedSkills() {
2228
+ if (this.skillsDeleting) return;
2229
+ const selected = Array.isArray(this.skillsSelectedNames)
2230
+ ? Array.from(new Set(this.skillsSelectedNames.map((item) => String(item || '').trim()).filter(Boolean)))
2231
+ : [];
2232
+ if (!selected.length) {
2233
+ this.showMessage('请先选择要删除的 skill', 'error');
2234
+ return;
2235
+ }
2236
+ const confirmed = window.confirm(`确认删除 ${selected.length} 个 skill 吗?此操作不可撤销。`);
2237
+ if (!confirmed) {
2238
+ return;
2239
+ }
2240
+
2241
+ this.skillsDeleting = true;
2242
+ try {
2243
+ const res = await api('delete-codex-skills', { names: selected });
2244
+ if (res.error) {
2245
+ this.showMessage(res.error, 'error');
2246
+ return;
2247
+ }
2248
+
2249
+ const deletedCount = Array.isArray(res.deleted) ? res.deleted.length : 0;
2250
+ const failedList = Array.isArray(res.failed) ? res.failed : [];
2251
+ const failedCount = failedList.length;
2252
+ if (failedCount > 0 && deletedCount > 0) {
2253
+ this.showMessage(`已删除 ${deletedCount} 个,失败 ${failedCount} 个`, 'error');
2254
+ } else if (failedCount > 0) {
2255
+ const first = failedList[0] && failedList[0].error ? failedList[0].error : '删除失败';
2256
+ this.showMessage(first, 'error');
2257
+ } else {
2258
+ this.showMessage(`已删除 ${deletedCount} 个 skill`, 'success');
2259
+ }
2260
+ await this.refreshSkillsList({ silent: true });
2261
+ } catch (e) {
2262
+ this.showMessage('删除 skill 失败', 'error');
2263
+ } finally {
2264
+ this.skillsDeleting = false;
2265
+ }
2266
+ },
2267
+
1757
2268
  async openOpenclawAgentsEditor() {
1758
2269
  this.setAgentsModalContext('openclaw');
1759
2270
  this.agentsLoading = true;
@@ -3860,4 +4371,3 @@
3860
4371
  app.mount('#app');
3861
4372
  });
3862
4373
 
3863
-