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/index.html CHANGED
@@ -13,7 +13,12 @@
13
13
  <body>
14
14
  <div id="app" class="container" v-cloak>
15
15
  <button class="fab-install" @click="openInstallModal" aria-label="安装 CLI">
16
- 安装 CLI
16
+ <span class="fab-install-icon" aria-hidden="true">
17
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8">
18
+ <path d="M10 4v8M6.5 8.5 10 12l3.5-3.5" stroke-linecap="round" stroke-linejoin="round"/>
19
+ <path d="M4 14.5h12" stroke-linecap="round"/>
20
+ </svg>
21
+ </span>
17
22
  </button>
18
23
 
19
24
  <div class="hero" v-if="!sessionStandalone">
@@ -26,6 +31,26 @@
26
31
  <span class="sr-only">本地配置中枢,统一管理 Codex / Claude Code / OpenClaw / 会话。</span>
27
32
  </div>
28
33
  </div>
34
+ <div v-if="!sessionStandalone" class="hero-github">
35
+ <a
36
+ class="github-badge github-badge-mobile"
37
+ href="https://github.com/SakuraByteCore/codexmate"
38
+ target="_blank"
39
+ rel="noopener"
40
+ title="打开 GitHub 仓库">
41
+ <span class="github-badge-left">
42
+ <svg class="github-badge-icon" viewBox="0 0 24 24" aria-hidden="true">
43
+ <path fill="currentColor" d="M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.38-3.37-1.38-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.36 1.12 2.94.86.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.09 0-1.13.39-2.06 1.03-2.79-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.36 9.36 0 0 1 12 6.8c.85 0 1.7.12 2.5.34 1.9-1.34 2.74-1.06 2.74-1.06.55 1.42.2 2.47.1 2.73.64.73 1.03 1.66 1.03 2.79 0 3.96-2.35 4.83-4.58 5.08.36.32.68.95.68 1.92 0 1.38-.01 2.5-.01 2.84 0 .27.18.59.69.49A10.04 10.04 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/>
44
+ </svg>
45
+ <span class="github-badge-label">GitHub</span>
46
+ </span>
47
+ <span class="github-badge-text" title="SakuraByteCore/codexmate">
48
+ <span class="github-owner">SakuraByteCore</span>
49
+ <span class="github-sep">/</span>
50
+ <span class="github-repo">codexmate</span>
51
+ </span>
52
+ </a>
53
+ </div>
29
54
 
30
55
  <div v-if="!sessionStandalone" class="top-tabs" role="tablist" aria-label="主导航">
31
56
  <button class="top-tab"
@@ -87,6 +112,24 @@
87
112
  <div class="brand-subtitle">
88
113
  配置 / 会话切换器
89
114
  </div>
115
+ <a
116
+ class="github-badge github-badge-rail"
117
+ href="https://github.com/SakuraByteCore/codexmate"
118
+ target="_blank"
119
+ rel="noopener"
120
+ title="打开 GitHub 仓库">
121
+ <span class="github-badge-left">
122
+ <svg class="github-badge-icon" viewBox="0 0 24 24" aria-hidden="true">
123
+ <path fill="currentColor" d="M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.38-3.37-1.38-.45-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.36 1.12 2.94.86.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.09 0-1.13.39-2.06 1.03-2.79-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.36 9.36 0 0 1 12 6.8c.85 0 1.7.12 2.5.34 1.9-1.34 2.74-1.06 2.74-1.06.55 1.42.2 2.47.1 2.73.64.73 1.03 1.66 1.03 2.79 0 3.96-2.35 4.83-4.58 5.08.36.32.68.95.68 1.92 0 1.38-.01 2.5-.01 2.84 0 .27.18.59.69.49A10.04 10.04 0 0 0 22 12.26C22 6.58 17.52 2 12 2z"/>
124
+ </svg>
125
+ <span class="github-badge-label">GitHub</span>
126
+ </span>
127
+ <span class="github-badge-text" title="SakuraByteCore/codexmate">
128
+ <span class="github-owner">SakuraByteCore</span>
129
+ <span class="github-sep">/</span>
130
+ <span class="github-repo">codexmate</span>
131
+ </span>
132
+ </a>
90
133
  </div>
91
134
 
92
135
  <div class="side-section" role="tablist" aria-label="配置管理">
@@ -343,6 +386,16 @@
343
386
  </button>
344
387
  </div>
345
388
 
389
+ <div class="selector-section">
390
+ <div class="selector-header">
391
+ <span class="selector-title">Skills 管理</span>
392
+ </div>
393
+ <div class="config-template-hint skills-hint-line">管理 <code>~/.codex/skills</code> 自定义 skills,弹窗提供统计概览、筛选检索、多选删除与跨应用导入。</div>
394
+ <button class="btn-tool" @click="openSkillsManager" :disabled="loading || !!initError || skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting">
395
+ {{ skillsLoading ? '加载中...' : '打开 Skills 管理' }}
396
+ </button>
397
+ </div>
398
+
346
399
  <div class="selector-section">
347
400
  <div class="selector-header">
348
401
  <span class="selector-title">配置维护</span>
@@ -357,8 +410,8 @@
357
410
  <div class="selector-header">
358
411
  <span class="selector-title">Codex 认证文件</span>
359
412
  </div>
360
- <div class="config-template-hint">
361
- 上传 JSON 后可切换账号(会写入 <code>~/.codex/auth.json</code>)。
413
+ <div class="config-template-hint hint-single-line" title="上传 JSON 切换账号(写入 ~/.codex/auth.json)。">
414
+ 上传 JSON 切换账号(写入 <code>~/.codex/auth.json</code>)。
362
415
  </div>
363
416
  <button class="btn-tool" @click="triggerCodexAuthUpload" :disabled="codexAuthImportLoading || loading || !!initError">
364
417
  {{ codexAuthImportLoading ? '上传中...' : '上传认证文件' }}
@@ -845,10 +898,10 @@
845
898
  </div>
846
899
  </div>
847
900
 
848
- <div :class="['session-preview', { active: !!activeSession }]">
901
+ <div :class="['session-preview', { active: !!activeSession }]" :ref="setSessionPreviewContainerRef">
849
902
  <template v-if="activeSession">
850
- <div class="session-preview-scroll">
851
- <div class="session-preview-header">
903
+ <div class="session-preview-scroll" :ref="setSessionPreviewScrollRef" @scroll="onSessionPreviewScroll">
904
+ <div class="session-preview-header" :ref="setSessionPreviewHeaderRef">
852
905
  <div>
853
906
  <div class="session-preview-title">{{ activeSession.title || activeSession.sessionId }}</div>
854
907
  <div class="session-preview-meta">
@@ -899,23 +952,44 @@
899
952
  </div>
900
953
 
901
954
  <div v-else class="session-preview-body">
902
- <div v-if="activeSessionDetailClipped" class="session-item-sub session-item-wrap">
903
- 仅展示最近 {{ activeSessionMessages.length }} 条消息。
904
- </div>
905
- <div
906
- v-for="(msg, idx) in activeSessionMessages"
907
- :key="getRecordRenderKey(msg, idx)"
908
- :class="['session-msg', msg.role === 'user' ? 'user' : (msg.role === 'system' ? 'system' : 'assistant')]">
909
- <div class="session-msg-header">
910
- <div class="session-msg-meta">
911
- <span class="session-msg-role">{{ msg.role === 'user' ? 'User' : (msg.role === 'system' ? 'System' : 'Assistant') }}</span>
912
- <span class="session-msg-time">{{ msg.timestamp || '' }}</span>
955
+ <div class="session-preview-messages">
956
+ <div v-if="activeSessionDetailClipped" class="session-item-sub session-item-wrap">
957
+ 仅展示最近 {{ activeSessionMessages.length }} 条消息。
958
+ </div>
959
+ <div
960
+ v-for="(msg, idx) in activeSessionMessages"
961
+ :key="getRecordRenderKey(msg, idx)"
962
+ :data-message-key="getRecordRenderKey(msg, idx)"
963
+ :ref="(el) => bindSessionMessageRef(getRecordRenderKey(msg, idx), el)"
964
+ :class="['session-msg', msg.normalizedRole === 'user' ? 'user' : (msg.normalizedRole === 'system' ? 'system' : 'assistant')]">
965
+ <div class="session-msg-header">
966
+ <div class="session-msg-meta">
967
+ <span class="session-msg-role">{{ msg.roleLabel || (msg.normalizedRole === 'user' ? 'User' : (msg.normalizedRole === 'system' ? 'System' : 'Assistant')) }}</span>
968
+ <span class="session-msg-time">{{ msg.timestamp || '' }}</span>
969
+ </div>
913
970
  </div>
971
+ <div class="session-msg-content">{{ msg.text || '' }}</div>
914
972
  </div>
915
- <div class="session-msg-content">{{ msg.text || '' }}</div>
916
973
  </div>
917
974
  </div>
918
975
  </div>
976
+ <aside v-if="sessionTimelineNodes.length" class="session-timeline" aria-label="会话时间轴">
977
+ <div class="session-timeline-track"></div>
978
+ <button
979
+ v-for="node in sessionTimelineNodes"
980
+ :key="'timeline-' + node.key"
981
+ type="button"
982
+ :class="['session-timeline-node', { active: sessionTimelineActiveKey === node.key }]"
983
+ :aria-current="sessionTimelineActiveKey === node.key ? 'true' : null"
984
+ :style="{ top: `${node.safePercent}%` }"
985
+ :title="node.title"
986
+ @click="jumpToSessionTimelineNode(node.key)">
987
+ <span class="sr-only">{{ node.title }}</span>
988
+ </button>
989
+ <div class="session-timeline-current" v-if="sessionTimelineActiveTitle">
990
+ {{ sessionTimelineActiveTitle }}
991
+ </div>
992
+ </aside>
919
993
  </template>
920
994
 
921
995
  <div v-else class="session-preview-empty">
@@ -1593,6 +1667,156 @@
1593
1667
  </div>
1594
1668
  </div>
1595
1669
 
1670
+ <div v-if="showSkillsModal" class="modal-overlay" @click.self="closeSkillsModal">
1671
+ <div class="modal modal-wide skills-modal">
1672
+ <div class="modal-header skills-modal-header">
1673
+ <div>
1674
+ <div class="modal-title">Skills 管理</div>
1675
+ <div class="skills-modal-subtitle">集中管理本地技能目录,支持检索筛选、多选删除与跨应用导入。</div>
1676
+ </div>
1677
+ <div class="modal-header-actions skills-modal-actions">
1678
+ <button class="btn-mini" @click="refreshSkillsList({ silent: false })" :disabled="skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting">
1679
+ {{ skillsLoading ? '刷新中...' : '刷新' }}
1680
+ </button>
1681
+ </div>
1682
+ </div>
1683
+
1684
+ <div class="form-group skills-root-group">
1685
+ <label class="form-label">Skills 目录</label>
1686
+ <div class="skills-root-box">{{ skillsRootPath || '~/.codex/skills' }}</div>
1687
+ </div>
1688
+
1689
+ <div class="skills-summary-strip">
1690
+ <div class="skills-summary-item">
1691
+ <span class="skills-summary-label">本地总数</span>
1692
+ <strong class="skills-summary-value">{{ skillsList.length }}</strong>
1693
+ </div>
1694
+ <div class="skills-summary-item">
1695
+ <span class="skills-summary-label">含 SKILL.md</span>
1696
+ <strong class="skills-summary-value">{{ skillsConfiguredCount }}</strong>
1697
+ </div>
1698
+ <div class="skills-summary-item">
1699
+ <span class="skills-summary-label">缺少 SKILL.md</span>
1700
+ <strong class="skills-summary-value">{{ skillsMissingSkillFileCount }}</strong>
1701
+ </div>
1702
+ <div class="skills-summary-item">
1703
+ <span class="skills-summary-label">可导入</span>
1704
+ <strong class="skills-summary-value">{{ skillsImportList.length }}</strong>
1705
+ </div>
1706
+ </div>
1707
+
1708
+ <div class="skills-panel">
1709
+ <div class="skills-panel-header">
1710
+ <div class="skills-panel-title-wrap">
1711
+ <div class="skills-panel-title">本地 Skills</div>
1712
+ <div class="skills-panel-note">支持关键词检索与状态筛选,勾选后可批量删除。</div>
1713
+ </div>
1714
+ <button
1715
+ class="btn-mini"
1716
+ @click="resetSkillsFilters"
1717
+ :disabled="skillsLoading || skillsDeleting || !skillsFilterDirty">
1718
+ 重置筛选
1719
+ </button>
1720
+ </div>
1721
+
1722
+ <div class="skills-filter-row">
1723
+ <input
1724
+ class="form-input"
1725
+ type="text"
1726
+ v-model.trim="skillsKeyword"
1727
+ aria-label="按名称或描述筛选 skill"
1728
+ placeholder="按目录名/显示名/描述检索">
1729
+ <select class="form-select skills-status-select" v-model="skillsStatusFilter" aria-label="按 SKILL.md 状态筛选 skill">
1730
+ <option value="all">全部状态</option>
1731
+ <option value="with-skill-file">仅含 SKILL.md</option>
1732
+ <option value="missing-skill-file">仅缺少 SKILL.md</option>
1733
+ </select>
1734
+ </div>
1735
+
1736
+ <div class="skill-toolbar">
1737
+ <label class="skill-select-all">
1738
+ <input type="checkbox" :checked="skillsAllSelected" @change="toggleAllSkillsSelection" :disabled="skillsLoading || skillsDeleting || skillsSelectableNames.length === 0">
1739
+ <span>{{ skillsAllSelected ? '取消全选' : '全选' }}</span>
1740
+ </label>
1741
+ <span class="skill-toolbar-count">已选 {{ skillsSelectedCount }}(筛选命中 {{ filteredSkillsList.length }} / {{ skillsList.length }},筛选内已选 {{ skillsVisibleSelectedCount }})</span>
1742
+ </div>
1743
+
1744
+ <div v-if="skillsList.length === 0" class="skills-empty-state">暂无可管理的 skill。</div>
1745
+ <div v-else-if="filteredSkillsList.length === 0" class="skills-empty-state">当前筛选条件下没有匹配的 skill。</div>
1746
+ <div v-else class="skill-list">
1747
+ <label
1748
+ class="skill-item"
1749
+ :class="{ selected: skillsSelectedNames.includes(skill.name) }"
1750
+ v-for="skill in filteredSkillsList"
1751
+ :key="'skill-' + skill.name">
1752
+ <input type="checkbox" v-model="skillsSelectedNames" :value="skill.name" :disabled="skillsDeleting">
1753
+ <div class="skill-item-main">
1754
+ <div class="skill-item-title">{{ skill.displayName || skill.name }}</div>
1755
+ <div v-if="skill.description" class="skill-item-description">{{ skill.description }}</div>
1756
+ <div class="skill-item-meta">
1757
+ <span class="skill-item-path" :title="skill.path">{{ skill.path }}</span>
1758
+ <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
1759
+ {{ skill.hasSkillFile ? '含 SKILL.md' : '缺少 SKILL.md' }}
1760
+ </span>
1761
+ <span class="pill source">{{ skill.sourceType === 'symlink' ? '符号链接' : '目录' }}</span>
1762
+ </div>
1763
+ </div>
1764
+ </label>
1765
+ </div>
1766
+ </div>
1767
+
1768
+ <div class="skills-panel skills-import-block">
1769
+ <div class="skills-panel-header">
1770
+ <div class="skills-panel-title-wrap">
1771
+ <div class="skills-import-title">跨应用导入(对齐 cc-switch 能力)</div>
1772
+ <div class="skills-panel-note">从其他应用扫描并导入未托管技能,支持多选批量导入。</div>
1773
+ </div>
1774
+ <button class="btn-mini" @click="scanImportableSkills" :disabled="skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting">
1775
+ {{ skillsScanningImports ? '扫描中...' : '扫描可导入' }}
1776
+ </button>
1777
+ </div>
1778
+ <div class="skill-toolbar">
1779
+ <label class="skill-select-all">
1780
+ <input type="checkbox" :checked="skillsImportAllSelected" @change="toggleAllSkillsImportSelection" :disabled="skillsScanningImports || skillsImporting || skillsImportSelectableKeys.length === 0">
1781
+ <span>{{ skillsImportAllSelected ? '取消全选' : '全选' }}</span>
1782
+ </label>
1783
+ <span class="skill-toolbar-count">已选 {{ skillsImportSelectedCount }} / {{ skillsImportSelectableKeys.length }},含 SKILL.md {{ skillsImportConfiguredCount }},缺失 {{ skillsImportMissingSkillFileCount }}</span>
1784
+ </div>
1785
+ <div v-if="skillsImportList.length === 0" class="skills-empty-state skills-import-empty">暂无可导入 skill,点击“扫描可导入”。</div>
1786
+ <div v-else class="skill-list skills-import-list">
1787
+ <label
1788
+ class="skill-item"
1789
+ :class="{ selected: skillsImportSelectedKeys.includes(buildSkillImportKey(skill)) }"
1790
+ v-for="skill in skillsImportList"
1791
+ :key="'import-skill-' + buildSkillImportKey(skill)">
1792
+ <input type="checkbox" v-model="skillsImportSelectedKeys" :value="buildSkillImportKey(skill)" :disabled="skillsImporting">
1793
+ <div class="skill-item-main">
1794
+ <div class="skill-item-title">{{ skill.displayName || skill.name }}</div>
1795
+ <div v-if="skill.description" class="skill-item-description">{{ skill.description }}</div>
1796
+ <div class="skill-item-meta">
1797
+ <span class="skill-item-path" :title="skill.sourcePath">{{ skill.sourcePath }}</span>
1798
+ <span class="pill source">{{ skill.sourceLabel }}</span>
1799
+ <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
1800
+ {{ skill.hasSkillFile ? '含 SKILL.md' : '缺少 SKILL.md' }}
1801
+ </span>
1802
+ </div>
1803
+ </div>
1804
+ </label>
1805
+ </div>
1806
+ </div>
1807
+
1808
+ <div class="btn-group">
1809
+ <button class="btn btn-cancel" @click="closeSkillsModal" :disabled="skillsDeleting || skillsImporting || skillsScanningImports">关闭</button>
1810
+ <button class="btn btn-confirm" @click="importSelectedSkills" :disabled="skillsImporting || skillsImportSelectedCount === 0">
1811
+ {{ skillsImporting ? '导入中...' : '导入选中' }}
1812
+ </button>
1813
+ <button class="btn btn-confirm btn-danger" @click="deleteSelectedSkills" :disabled="skillsDeleting || skillsSelectedCount === 0">
1814
+ {{ skillsDeleting ? '删除中...' : '删除选中' }}
1815
+ </button>
1816
+ </div>
1817
+ </div>
1818
+ </div>
1819
+
1596
1820
  <!-- Toast通知 -->
1597
1821
 
1598
1822
  <!-- Toast -->
@@ -1602,4 +1826,3 @@
1602
1826
  <script type="module" src="web-ui/app.js"></script>
1603
1827
  </body>
1604
1828
  </html>
1605
-
package/web-ui/logic.mjs CHANGED
@@ -128,10 +128,31 @@ export function buildSpeedTestIssue(name, result) {
128
128
 
129
129
  // Session filtering helpers
130
130
  export function isSessionQueryEnabled(source) {
131
- const normalized = (source || '').toLowerCase();
131
+ const normalized = normalizeSessionSource(source, '');
132
132
  return normalized === 'codex' || normalized === 'claude' || normalized === 'all';
133
133
  }
134
134
 
135
+ export function normalizeSessionSource(source, fallback = 'all') {
136
+ const normalized = typeof source === 'string'
137
+ ? source.trim().toLowerCase()
138
+ : '';
139
+ if (normalized === 'codex' || normalized === 'claude' || normalized === 'all') {
140
+ return normalized;
141
+ }
142
+ return fallback;
143
+ }
144
+
145
+ export function normalizeSessionPathFilter(pathFilter) {
146
+ return typeof pathFilter === 'string' ? pathFilter.trim() : '';
147
+ }
148
+
149
+ export function buildSessionFilterCacheState(source, pathFilter) {
150
+ return {
151
+ source: normalizeSessionSource(source, 'all'),
152
+ pathFilter: normalizeSessionPathFilter(pathFilter)
153
+ };
154
+ }
155
+
135
156
  export function buildSessionListParams(options = {}) {
136
157
  const {
137
158
  source = 'all',
@@ -155,3 +176,128 @@ export function buildSessionListParams(options = {}) {
155
176
  forceRefresh: true
156
177
  };
157
178
  }
179
+
180
+ export function normalizeSessionMessageRole(role) {
181
+ const value = typeof role === 'string' ? role.trim().toLowerCase() : '';
182
+ if (value === 'user' || value === 'assistant' || value === 'system') {
183
+ return value;
184
+ }
185
+ return 'assistant';
186
+ }
187
+
188
+ function toRoleMeta(role) {
189
+ if (role === 'user') {
190
+ return { role: 'user', roleLabel: 'User', roleShort: 'U' };
191
+ }
192
+ if (role === 'assistant') {
193
+ return { role: 'assistant', roleLabel: 'Assistant', roleShort: 'A' };
194
+ }
195
+ if (role === 'system') {
196
+ return { role: 'system', roleLabel: 'System', roleShort: 'S' };
197
+ }
198
+ return { role: 'mixed', roleLabel: 'Mixed', roleShort: 'M' };
199
+ }
200
+
201
+ function clampTimelinePercent(percent) {
202
+ return Math.max(6, Math.min(94, percent));
203
+ }
204
+
205
+ export function formatSessionTimelineTimestamp(timestamp) {
206
+ const value = typeof timestamp === 'string' ? timestamp.trim() : '';
207
+ if (!value) return '';
208
+
209
+ // 优先按 ISO/常见时间串抽取,避免本地时区格式差异导致的展示抖动。
210
+ const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/);
211
+ if (matched) {
212
+ const second = matched[6] || '00';
213
+ return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`;
214
+ }
215
+
216
+ return value;
217
+ }
218
+
219
+ export function buildSessionTimelineNodes(messages = [], options = {}) {
220
+ const list = Array.isArray(messages) ? messages : [];
221
+ const getKey = typeof options.getKey === 'function'
222
+ ? options.getKey
223
+ : ((_message, index) => `msg-${index}`);
224
+ const total = list.length;
225
+ const rawMaxMarkers = Number(options.maxMarkers);
226
+ const maxMarkers = Number.isFinite(rawMaxMarkers)
227
+ ? Math.max(1, Math.min(80, Math.floor(rawMaxMarkers)))
228
+ : 30;
229
+
230
+ const buildSingleNode = (message, index) => {
231
+ const role = normalizeSessionMessageRole(message && (message.normalizedRole || message.role));
232
+ const roleMeta = toRoleMeta(role);
233
+ const key = String(getKey(message, index) || `msg-${index}`);
234
+ const displayTime = formatSessionTimelineTimestamp(message && message.timestamp ? message.timestamp : '');
235
+ const title = displayTime
236
+ ? `#${index + 1} · ${roleMeta.roleLabel} · ${displayTime}`
237
+ : `#${index + 1} · ${roleMeta.roleLabel}`;
238
+ const percent = total <= 1 ? 0 : (index / (total - 1)) * 100;
239
+ return {
240
+ key,
241
+ role: roleMeta.role,
242
+ roleLabel: roleMeta.roleLabel,
243
+ roleShort: roleMeta.roleShort,
244
+ displayTime,
245
+ title,
246
+ percent,
247
+ safePercent: clampTimelinePercent(percent)
248
+ };
249
+ };
250
+
251
+ if (total <= maxMarkers) {
252
+ return list.map((message, index) => buildSingleNode(message, index));
253
+ }
254
+
255
+ const nodes = [];
256
+ const bucketWidth = total / maxMarkers;
257
+ for (let bucket = 0; bucket < maxMarkers; bucket += 1) {
258
+ let start = Math.floor(bucket * bucketWidth);
259
+ if (nodes.length && start <= nodes[nodes.length - 1].endIndex) {
260
+ start = nodes[nodes.length - 1].endIndex + 1;
261
+ }
262
+ if (start >= total) {
263
+ break;
264
+ }
265
+ let end = Math.floor((bucket + 1) * bucketWidth) - 1;
266
+ end = Math.max(start, Math.min(total - 1, end));
267
+ const targetIndex = Math.min(total - 1, start + Math.floor((end - start) / 2));
268
+ const targetMessage = list[targetIndex] || null;
269
+ const key = String(getKey(targetMessage, targetIndex) || `msg-${targetIndex}`);
270
+ const percent = total <= 1 ? 0 : (targetIndex / (total - 1)) * 100;
271
+ const messagesInGroup = end - start + 1;
272
+ const roleSet = new Set();
273
+ for (let i = start; i <= end; i += 1) {
274
+ roleSet.add(normalizeSessionMessageRole(list[i] && (list[i].normalizedRole || list[i].role)));
275
+ }
276
+ const roleValue = roleSet.size === 1 ? Array.from(roleSet)[0] : 'mixed';
277
+ const roleMeta = toRoleMeta(roleValue);
278
+ const firstTime = formatSessionTimelineTimestamp(list[start] && list[start].timestamp ? list[start].timestamp : '');
279
+ const lastTime = formatSessionTimelineTimestamp(list[end] && list[end].timestamp ? list[end].timestamp : '');
280
+ let displayTime = '';
281
+ if (firstTime && lastTime) {
282
+ displayTime = firstTime === lastTime ? firstTime : `${firstTime} ~ ${lastTime}`;
283
+ } else {
284
+ displayTime = firstTime || lastTime;
285
+ }
286
+ const titleBase = `#${start + 1}-${end + 1} · ${messagesInGroup} msgs · ${roleMeta.roleLabel}`;
287
+ const title = displayTime ? `${titleBase} · ${displayTime}` : titleBase;
288
+ nodes.push({
289
+ key,
290
+ role: roleMeta.role,
291
+ roleLabel: roleMeta.roleLabel,
292
+ roleShort: roleMeta.roleShort,
293
+ displayTime,
294
+ title,
295
+ percent,
296
+ safePercent: clampTimelinePercent(percent),
297
+ startIndex: start,
298
+ endIndex: end,
299
+ messageCount: messagesInGroup
300
+ });
301
+ }
302
+ return nodes;
303
+ }