codexmate 0.0.17 → 0.0.19

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
@@ -56,48 +56,66 @@
56
56
  <button class="top-tab"
57
57
  id="tab-config-codex"
58
58
  role="tab"
59
+ data-main-tab="config"
60
+ data-config-mode="codex"
59
61
  :tabindex="mainTab === 'config' && configMode === 'codex' ? 0 : -1"
60
62
  :aria-selected="mainTab === 'config' && configMode === 'codex'"
61
- :aria-pressed="mainTab === 'config' && configMode === 'codex'"
62
63
  aria-controls="panel-config-provider"
63
- :class="{ active: mainTab === 'config' && configMode === 'codex' }"
64
- @click="switchConfigMode('codex')">Codex 配置</button>
64
+ :class="{ active: isConfigModeNavActive('codex') }"
65
+ @pointerdown="onConfigTabPointerDown('codex', $event)"
66
+ @click="onConfigTabClick('codex', $event)">Codex 配置</button>
65
67
  <button class="top-tab"
66
68
  id="tab-config-claude"
67
69
  role="tab"
70
+ data-main-tab="config"
71
+ data-config-mode="claude"
68
72
  :tabindex="mainTab === 'config' && configMode === 'claude' ? 0 : -1"
69
73
  :aria-selected="mainTab === 'config' && configMode === 'claude'"
70
- :aria-pressed="mainTab === 'config' && configMode === 'claude'"
71
74
  aria-controls="panel-config-claude"
72
- :class="{ active: mainTab === 'config' && configMode === 'claude' }"
73
- @click="switchConfigMode('claude')">Claude Code 配置</button>
75
+ :class="{ active: isConfigModeNavActive('claude') }"
76
+ @pointerdown="onConfigTabPointerDown('claude', $event)"
77
+ @click="onConfigTabClick('claude', $event)">Claude Code 配置</button>
74
78
  <button class="top-tab"
75
79
  id="tab-config-openclaw"
76
80
  role="tab"
81
+ data-main-tab="config"
82
+ data-config-mode="openclaw"
77
83
  :tabindex="mainTab === 'config' && configMode === 'openclaw' ? 0 : -1"
78
84
  :aria-selected="mainTab === 'config' && configMode === 'openclaw'"
79
- :aria-pressed="mainTab === 'config' && configMode === 'openclaw'"
80
85
  aria-controls="panel-config-openclaw"
81
- :class="{ active: mainTab === 'config' && configMode === 'openclaw' }"
82
- @click="switchConfigMode('openclaw')">OpenClaw 配置</button>
86
+ :class="{ active: isConfigModeNavActive('openclaw') }"
87
+ @pointerdown="onConfigTabPointerDown('openclaw', $event)"
88
+ @click="onConfigTabClick('openclaw', $event)">OpenClaw 配置</button>
83
89
  <button class="top-tab"
84
90
  id="tab-sessions"
85
91
  role="tab"
92
+ data-main-tab="sessions"
86
93
  :tabindex="mainTab === 'sessions' ? 0 : -1"
87
94
  :aria-selected="mainTab === 'sessions'"
88
- :aria-pressed="mainTab === 'sessions'"
89
95
  aria-controls="panel-sessions"
90
- :class="{ active: mainTab === 'sessions' }"
91
- @click="switchMainTab('sessions')">会话浏览</button>
96
+ :class="{ active: isMainTabNavActive('sessions') }"
97
+ @pointerdown="onMainTabPointerDown('sessions', $event)"
98
+ @click="onMainTabClick('sessions', $event)">会话浏览</button>
99
+ <button class="top-tab"
100
+ id="tab-market"
101
+ role="tab"
102
+ data-main-tab="market"
103
+ :tabindex="mainTab === 'market' ? 0 : -1"
104
+ :aria-selected="mainTab === 'market'"
105
+ aria-controls="panel-market"
106
+ :class="{ active: isMainTabNavActive('market') }"
107
+ @pointerdown="onMainTabPointerDown('market', $event)"
108
+ @click="onMainTabClick('market', $event)">技能市场</button>
92
109
  <button class="top-tab"
93
110
  id="tab-settings"
94
111
  role="tab"
112
+ data-main-tab="settings"
95
113
  :tabindex="mainTab === 'settings' ? 0 : -1"
96
114
  :aria-selected="mainTab === 'settings'"
97
- :aria-pressed="mainTab === 'settings'"
98
115
  aria-controls="panel-settings"
99
- :class="{ active: mainTab === 'settings' }"
100
- @click="switchMainTab('settings')">设置</button>
116
+ :class="{ active: isMainTabNavActive('settings') }"
117
+ @pointerdown="onMainTabPointerDown('settings', $event)"
118
+ @click="onMainTabClick('settings', $event)">设置</button>
101
119
  </div>
102
120
 
103
121
  <div :class="['app-shell', { standalone: sessionStandalone }]">
@@ -137,12 +155,14 @@
137
155
  <button
138
156
  role="tab"
139
157
  id="side-tab-config-codex"
158
+ data-main-tab="config"
159
+ data-config-mode="codex"
140
160
  aria-controls="panel-config-provider"
141
161
  :tabindex="mainTab === 'config' && configMode === 'codex' ? 0 : -1"
142
162
  :aria-selected="mainTab === 'config' && configMode === 'codex'"
143
- :aria-pressed="mainTab === 'config' && configMode === 'codex'"
144
- :class="['side-item', { active: mainTab === 'config' && configMode === 'codex' }]"
145
- @click="switchConfigMode('codex')">
163
+ :class="['side-item', { active: isConfigModeNavActive('codex') }]"
164
+ @pointerdown="onConfigTabPointerDown('codex', $event)"
165
+ @click="onConfigTabClick('codex', $event)">
146
166
  <div class="side-item-title">Codex 配置</div>
147
167
  <div class="side-item-meta">
148
168
  <span>提供商 / 模型</span>
@@ -152,12 +172,14 @@
152
172
  <button
153
173
  role="tab"
154
174
  id="side-tab-config-claude"
175
+ data-main-tab="config"
176
+ data-config-mode="claude"
155
177
  aria-controls="panel-config-claude"
156
178
  :tabindex="mainTab === 'config' && configMode === 'claude' ? 0 : -1"
157
179
  :aria-selected="mainTab === 'config' && configMode === 'claude'"
158
- :aria-pressed="mainTab === 'config' && configMode === 'claude'"
159
- :class="['side-item', { active: mainTab === 'config' && configMode === 'claude' }]"
160
- @click="switchConfigMode('claude')">
180
+ :class="['side-item', { active: isConfigModeNavActive('claude') }]"
181
+ @pointerdown="onConfigTabPointerDown('claude', $event)"
182
+ @click="onConfigTabClick('claude', $event)">
161
183
  <div class="side-item-title">Claude Code 配置</div>
162
184
  <div class="side-item-meta">
163
185
  <span>Base URL / Key</span>
@@ -167,12 +189,14 @@
167
189
  <button
168
190
  role="tab"
169
191
  id="side-tab-config-openclaw"
192
+ data-main-tab="config"
193
+ data-config-mode="openclaw"
170
194
  aria-controls="panel-config-openclaw"
171
195
  :tabindex="mainTab === 'config' && configMode === 'openclaw' ? 0 : -1"
172
196
  :aria-selected="mainTab === 'config' && configMode === 'openclaw'"
173
- :aria-pressed="mainTab === 'config' && configMode === 'openclaw'"
174
- :class="['side-item', { active: mainTab === 'config' && configMode === 'openclaw' }]"
175
- @click="switchConfigMode('openclaw')">
197
+ :class="['side-item', { active: isConfigModeNavActive('openclaw') }]"
198
+ @pointerdown="onConfigTabPointerDown('openclaw', $event)"
199
+ @click="onConfigTabClick('openclaw', $event)">
176
200
  <div class="side-item-title">OpenClaw 配置</div>
177
201
  <div class="side-item-meta">
178
202
  <span>JSON5 / Workspace</span>
@@ -186,12 +210,13 @@
186
210
  <button
187
211
  role="tab"
188
212
  id="side-tab-sessions"
213
+ data-main-tab="sessions"
189
214
  aria-controls="panel-sessions"
190
215
  :tabindex="mainTab === 'sessions' ? 0 : -1"
191
216
  :aria-selected="mainTab === 'sessions'"
192
- :aria-pressed="mainTab === 'sessions'"
193
- :class="['side-item', { active: mainTab === 'sessions' }]"
194
- @click="switchMainTab('sessions')">
217
+ :class="['side-item', { active: isMainTabNavActive('sessions') }]"
218
+ @pointerdown="onMainTabPointerDown('sessions', $event)"
219
+ @click="onMainTabClick('sessions', $event)">
195
220
  <div class="side-item-title">会话浏览</div>
196
221
  <div class="side-item-meta">
197
222
  <span>快速预览 / 导出</span>
@@ -200,17 +225,38 @@
200
225
  </button>
201
226
  </div>
202
227
 
228
+ <div class="side-section" role="tablist" aria-label="技能市场">
229
+ <div class="side-section-title">技能市场</div>
230
+ <button
231
+ role="tab"
232
+ id="side-tab-market"
233
+ data-main-tab="market"
234
+ aria-controls="panel-market"
235
+ :tabindex="mainTab === 'market' ? 0 : -1"
236
+ :aria-selected="mainTab === 'market'"
237
+ :class="['side-item', { active: isMainTabNavActive('market') }]"
238
+ @pointerdown="onMainTabPointerDown('market', $event)"
239
+ @click="onMainTabClick('market', $event)">
240
+ <div class="side-item-title">市场</div>
241
+ <div class="side-item-meta">
242
+ <span>{{ skillsTargetLabel }} / 本地 Skills</span>
243
+ <span>已装 {{ skillsList.length }} · 可导入 {{ skillsImportList.length }}</span>
244
+ </div>
245
+ </button>
246
+ </div>
247
+
203
248
  <div class="side-section" role="tablist" aria-label="设置">
204
249
  <div class="side-section-title">设置</div>
205
250
  <button
206
251
  role="tab"
207
252
  id="side-tab-settings"
253
+ data-main-tab="settings"
208
254
  aria-controls="panel-settings"
209
255
  :tabindex="mainTab === 'settings' ? 0 : -1"
210
256
  :aria-selected="mainTab === 'settings'"
211
- :aria-pressed="mainTab === 'settings'"
212
- :class="['side-item', { active: mainTab === 'settings' }]"
213
- @click="switchMainTab('settings')">
257
+ :class="['side-item', { active: isMainTabNavActive('settings') }]"
258
+ @pointerdown="onMainTabPointerDown('settings', $event)"
259
+ @click="onMainTabClick('settings', $event)">
214
260
  <div class="side-item-title">设置</div>
215
261
  <div class="side-item-meta">
216
262
  <span>数据管理 / 下载</span>
@@ -221,7 +267,7 @@
221
267
  <main class="main-panel">
222
268
  <div class="panel-header" v-if="!sessionStandalone">
223
269
  <h1 class="main-title">
224
- {{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : '设置') }}
270
+ {{ mainTab === 'config' ? '配置中心' : (mainTab === 'sessions' ? '会话浏览' : (mainTab === 'market' ? '技能市场' : '设置')) }}
225
271
  </h1>
226
272
  <p class="subtitle" v-if="mainTab === 'config'">
227
273
  配置中枢:管理 Codex / Claude / OpenClaw
@@ -230,6 +276,9 @@
230
276
  <p class="subtitle" v-else-if="mainTab === 'sessions'">
231
277
  浏览、导出或独立查看 Codex / Claude 会话记录。
232
278
  </p>
279
+ <p class="subtitle" v-else-if="mainTab === 'market'">
280
+ 统一管理 Codex / Claude Skills,并聚焦本地导入与分发。
281
+ </p>
233
282
  </div>
234
283
 
235
284
  <div class="status-strip" v-if="!sessionStandalone && mainTab === 'config'">
@@ -282,29 +331,47 @@
282
331
  <span class="value">{{ sessionsList.length }}</span>
283
332
  </div>
284
333
  </div>
334
+ <div class="status-strip" v-else-if="!sessionStandalone && mainTab === 'market'">
335
+ <div class="status-chip">
336
+ <span class="label">当前目标</span>
337
+ <span class="value">{{ skillsTargetLabel }}</span>
338
+ </div>
339
+ <div class="status-chip">
340
+ <span class="label">本地 Skills</span>
341
+ <span class="value">{{ skillsList.length }}</span>
342
+ </div>
343
+ <div class="status-chip">
344
+ <span class="label">可导入</span>
345
+ <span class="value">{{ skillsImportList.length }}</span>
346
+ </div>
347
+ <div class="status-chip">
348
+ <span class="label">可直接导入</span>
349
+ <span class="value">{{ skillsImportConfiguredCount }}</span>
350
+ </div>
351
+ </div>
285
352
  <div
286
- v-if="!sessionStandalone && mainTab === 'config' && isProviderConfigMode && forceCompactLayout && !loading && !initError && providersList.length > 1"
353
+ v-if="!sessionStandalone && mainTab === 'config' && isProviderConfigMode && forceCompactLayout && !loading && !initError && displayProvidersList.length > 1"
287
354
  class="provider-fast-switch">
288
355
  <label class="provider-fast-switch-label" for="provider-fast-switch-select">快速切换提供商</label>
289
356
  <select
290
357
  id="provider-fast-switch-select"
291
358
  class="provider-fast-switch-select"
292
- :value="currentProvider"
359
+ :value="displayCurrentProvider"
293
360
  @change="quickSwitchProvider($event.target.value)">
294
- <option v-for="provider in providersList" :key="'quick-switch-' + provider.name" :value="provider.name">
361
+ <option v-for="provider in displayProvidersList" :key="'quick-switch-' + provider.name" :value="provider.name">
295
362
  {{ provider.name }}
296
363
  </option>
297
364
  </select>
298
365
  </div>
299
366
 
300
367
  <div v-if="false && mainTab === 'config' && !sessionStandalone" class="config-subtabs">
301
- <button :class="['config-subtab', { active: configMode === 'codex' }]" @click="switchConfigMode('codex')">
368
+ <button :class="['config-subtab', { active: configMode === 'codex' }]" @click="onConfigTabClick('codex', $event)">
302
369
  Codex 配置
303
370
  </button>
304
- <button :class="['config-subtab', { active: configMode === 'claude' }]" @click="switchConfigMode('claude')">
371
+ <button :class="['config-subtab', { active: configMode === 'claude' }]" @click="onConfigTabClick('claude', $event)">
305
372
  Claude Code 配置
306
373
  </button>
307
- <button :class="['config-subtab', { active: configMode === 'openclaw' }]" @click="switchConfigMode('openclaw')">
374
+ <button :class="['config-subtab', { active: configMode === 'openclaw' }]" @click="onConfigTabClick('openclaw', $event)">
308
375
  OpenClaw 配置
309
376
  </button>
310
377
  </div>
@@ -365,7 +432,7 @@
365
432
  Codex 配置需先改模板,再手动应用。
366
433
  </div>
367
434
  <div class="config-template-hint" v-else-if="activeProviderBridgeHint">
368
- {{ activeProviderBridgeHint }} 模板、认证和代理仅在 Codex 模式下可编辑。
435
+ {{ activeProviderBridgeHint }} 模板仅在 Codex 模式下可编辑。
369
436
  </div>
370
437
  <button class="btn-tool btn-template-editor" v-if="isCodexConfigMode" @click="openConfigTemplateEditor" :disabled="loading || !!initError">
371
438
  打开 Config 模板编辑器
@@ -414,8 +481,8 @@
414
481
  <div class="selector-header">
415
482
  <span class="selector-title">Skills 管理</span>
416
483
  </div>
417
- <div class="config-template-hint skills-hint-line">管理 <code>~/.codex/skills</code> 自定义 skills,弹窗提供统计概览、筛选检索、多选删除、ZIP 导入与导出。</div>
418
- <button class="btn-tool" @click="openSkillsManager" :disabled="loading || !!initError || skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting || skillsZipImporting || skillsExporting">
484
+ <div class="config-template-hint skills-hint-line">默认打开 Codex skills 管理;市场页已支持切换到 Claude Code 并查看本地概览与导入来源。</div>
485
+ <button class="btn-tool" @click="openSkillsManager({ targetApp: 'codex' })" :disabled="loading || !!initError || skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting || skillsZipImporting || skillsExporting">
419
486
  {{ skillsLoading ? '加载中...' : '打开 Skills 管理' }}
420
487
  </button>
421
488
  </div>
@@ -430,102 +497,6 @@
430
497
  </button>
431
498
  </div>
432
499
 
433
- <div class="selector-section">
434
- <div class="selector-header">
435
- <span class="selector-title">Codex 认证文件</span>
436
- </div>
437
- <div class="config-template-hint hint-single-line" title="上传 JSON 切换账号(写入 ~/.codex/auth.json)。">
438
- 上传 JSON 切换账号(写入 <code>~/.codex/auth.json</code>)。
439
- </div>
440
- <button class="btn-tool" @click="triggerCodexAuthUpload" :disabled="codexAuthImportLoading || loading || !!initError">
441
- {{ codexAuthImportLoading ? '上传中...' : '上传认证文件' }}
442
- </button>
443
- <input
444
- ref="codexAuthImportInput"
445
- type="file"
446
- accept=".json,application/json"
447
- style="display:none"
448
- @change="handleCodexAuthImportChange">
449
- <div v-if="codexAuthProfiles.length === 0" class="form-hint">暂无认证文件。</div>
450
- <div v-else class="auth-profile-list">
451
- <div class="auth-profile-item" v-for="profile in codexAuthProfiles" :key="'auth-' + profile.name">
452
- <div class="auth-profile-header">
453
- <div class="auth-profile-main">
454
- <div class="auth-profile-title">{{ profile.name }}</div>
455
- <div class="auth-profile-meta">
456
- <span class="provider-source">{{ profile.type || 'unknown' }}</span>
457
- <span :class="['pill', profile.current ? 'configured' : 'empty']">
458
- {{ profile.current ? '当前' : '备用' }}
459
- </span>
460
- </div>
461
- </div>
462
- <div class="auth-profile-actions">
463
- <button
464
- class="btn-mini"
465
- :disabled="profile.current || codexAuthSwitching[profile.name]"
466
- @click="switchCodexAuthProfile(profile.name)">
467
- {{ codexAuthSwitching[profile.name] ? '切换中...' : (profile.current ? '当前使用' : '切换账号') }}
468
- </button>
469
- <button
470
- class="btn-mini delete"
471
- :disabled="codexAuthDeleting[profile.name]"
472
- @click="deleteCodexAuthProfile(profile.name)">
473
- {{ codexAuthDeleting[profile.name] ? '删除中...' : '删除' }}
474
- </button>
475
- </div>
476
- </div>
477
- <div class="auth-profile-grid">
478
- <div v-if="profile.email" class="auth-profile-row">
479
- <span class="auth-profile-key">email</span>
480
- <span class="auth-profile-value">{{ profile.email }}</span>
481
- </div>
482
- <div v-if="profile.accountId" class="auth-profile-row">
483
- <span class="auth-profile-key">account_id</span>
484
- <span class="auth-profile-value">{{ profile.accountId }}</span>
485
- </div>
486
- <div v-if="profile.expired" class="auth-profile-row">
487
- <span class="auth-profile-key">expired</span>
488
- <span class="auth-profile-value">{{ profile.expired }}</span>
489
- </div>
490
- </div>
491
- </div>
492
- </div>
493
- </div>
494
-
495
- <div class="selector-section">
496
- <div class="selector-header">
497
- <span class="selector-title">内建代理</span>
498
- </div>
499
- <button class="btn-mini" @click="showProxyAdvanced = !showProxyAdvanced">
500
- {{ showProxyAdvanced ? '收起高级设置' : '展开高级设置(端口/鉴权)' }}
501
- </button>
502
- <div v-if="showProxyAdvanced">
503
- <div class="list-row">
504
- <label class="form-label">上游 Provider</label>
505
- <select class="form-input" v-model="proxySettings.provider" @change="saveProxySettings({ silent: true })">
506
- <option value="">自动(当前 provider)</option>
507
- <option v-for="name in proxyProviderOptions" :key="'proxy-provider-' + name" :value="name">{{ name }}</option>
508
- </select>
509
- <label class="form-label">端口</label>
510
- <input v-model.number="proxySettings.port" class="form-input" type="number" min="1" max="65535" placeholder="8318" @change="saveProxySettings({ silent: true })">
511
- </div>
512
- <div class="list-row">
513
- <label class="form-label">鉴权来源</label>
514
- <select class="form-input" v-model="proxySettings.authSource" @change="saveProxySettings({ silent: true })">
515
- <option value="provider">provider 优先</option>
516
- <option value="profile">仅当前认证文件</option>
517
- <option value="none">不注入鉴权头</option>
518
- </select>
519
- <label class="form-label">超时(ms)</label>
520
- <input v-model.number="proxySettings.timeoutMs" class="form-input" type="number" min="1000" step="500" placeholder="30000" @change="saveProxySettings({ silent: true })">
521
- </div>
522
- <div class="form-hint">高级参数修改后自动保存。</div>
523
- </div>
524
- <div class="config-template-hint" v-if="proxyRuntime">
525
- 运行中:入口 <code>{{ proxyRuntimeDisplayProvider }}</code>
526
- <span v-if="proxyRuntime.upstreamProvider">(上游 <code>{{ proxyRuntime.upstreamProvider }}</code>)</span>
527
- </div>
528
- </div>
529
500
  </template>
530
501
 
531
502
  <div class="selector-section">
@@ -538,18 +509,18 @@
538
509
  </div>
539
510
 
540
511
  <div v-if="!loading && !initError" class="card-list">
541
- <div v-for="provider in providersList" :key="provider.name"
542
- :class="['card', { active: currentProvider === provider.name }]"
512
+ <div v-for="provider in displayProvidersList" :key="provider.name"
513
+ :class="['card', { active: displayCurrentProvider === provider.name }]"
543
514
  @click="switchProvider(provider.name)">
544
515
  <div class="card-leading">
545
516
  <div class="card-icon">{{ provider.name.charAt(0).toUpperCase() }}</div>
546
517
  <div class="card-content">
547
518
  <div class="card-title">
548
519
  <span>{{ provider.name }}</span>
549
- <span v-if="provider.readOnly" class="provider-readonly-badge">内建</span>
520
+ <span v-if="provider.readOnly" class="provider-readonly-badge">系统</span>
550
521
  </div>
551
522
  <div class="card-subtitle">
552
- {{ provider.readOnly ? '系统内建本地代理(自动维护)' : (provider.url || '未设置 URL') }}
523
+ {{ provider.url || '未设置 URL' }}
553
524
  </div>
554
525
  </div>
555
526
  </div>
@@ -569,10 +540,11 @@
569
540
  <button
570
541
  v-if="!provider.readOnly"
571
542
  class="card-action-btn"
572
- :class="{ loading: providerShareLoading[provider.name], disabled: !shouldAllowProviderShare(provider) }"
573
- :disabled="!shouldAllowProviderShare(provider)"
543
+ :class="{ loading: providerShareLoading[provider.name] }"
544
+ disabled
574
545
  @click="copyProviderShareCommand(provider)"
575
- :title="shouldAllowProviderShare(provider) ? '分享导入命令' : '本地入口不可分享'">
546
+ title="分享导入命令(暂时禁用)"
547
+ aria-label="Share import command">
576
548
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
577
549
  <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/>
578
550
  <path d="M16 6l-4-4-4 4"/>
@@ -885,14 +857,16 @@
885
857
  </div>
886
858
 
887
859
  <div v-else :class="['session-layout', { 'session-standalone': sessionStandalone }]">
888
- <div v-if="!sessionStandalone" class="session-list">
860
+ <div v-if="!sessionStandalone && sessionListRenderEnabled" class="session-list">
889
861
  <div
890
- v-for="session in sessionsList"
862
+ v-for="session in sortedSessionsList"
891
863
  :key="session.source + '-' + session.sessionId + '-' + session.filePath"
864
+ v-memo="[activeSessionExportKey === getSessionExportKey(session), session.messageCount, session.updatedAt, session.title, session.sourceLabel, isSessionPinned(session), sessionsLoading]"
892
865
  :class="[
893
866
  'session-item',
894
867
  {
895
- active: activeSession && getSessionExportKey(activeSession) === getSessionExportKey(session)
868
+ active: activeSessionExportKey === getSessionExportKey(session),
869
+ pinned: isSessionPinned(session)
896
870
  }
897
871
  ]"
898
872
  @click="selectSession(session)">
@@ -902,6 +876,20 @@
902
876
  <span class="session-count-badge">{{ session.messageCount ?? 0 }}</span>
903
877
  </div>
904
878
  <div class="session-item-actions">
879
+ <button
880
+ class="session-item-copy session-item-pin"
881
+ @click.stop="toggleSessionPin(session)"
882
+ :disabled="sessionsLoading"
883
+ :aria-label="isSessionPinned(session) ? '取消置顶' : '置顶'"
884
+ :title="isSessionPinned(session) ? '取消置顶' : '置顶'"
885
+ :aria-pressed="isSessionPinned(session)">
886
+ <svg v-if="isSessionPinned(session)" class="pin-icon" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.6">
887
+ <path d="M12 22s8-6 8-12a8 8 0 1 0-16 0c0 6 8 12 8 12z"></path>
888
+ </svg>
889
+ <svg v-else class="pin-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
890
+ <path d="M12 22s8-6 8-12a8 8 0 1 0-16 0c0 6 8 12 8 12z"></path>
891
+ </svg>
892
+ </button>
905
893
  <button
906
894
  v-if="isResumeCommandAvailable(session)"
907
895
  class="session-item-copy"
@@ -922,6 +910,7 @@
922
910
  </div>
923
911
  </div>
924
912
  </div>
913
+ <div v-else-if="!sessionStandalone" class="session-list session-list-placeholder"></div>
925
914
 
926
915
  <div :class="['session-preview', { active: !!activeSession }]" :ref="setSessionPreviewContainerRef">
927
916
  <template v-if="activeSession">
@@ -946,7 +935,7 @@
946
935
  class="btn-session-delete"
947
936
  @click="deleteSession(activeSession)"
948
937
  :disabled="!activeSession || sessionsLoading || sessionDeleting[getSessionExportKey(activeSession)]">
949
- {{ (activeSession && sessionDeleting[getSessionExportKey(activeSession)]) ? '删除中...' : '删除会话' }}
938
+ {{ (activeSession && sessionDeleting[getSessionExportKey(activeSession)]) ? '移入中...' : '移入回收站' }}
950
939
  </button>
951
940
  <button
952
941
  class="btn-session-export"
@@ -964,7 +953,7 @@
964
953
  </div>
965
954
  </div>
966
955
 
967
- <div v-if="sessionDetailLoading" class="session-preview-empty">
956
+ <div v-if="sessionDetailLoading && !sessionPreviewLoadingMore" class="session-preview-empty">
968
957
  正在加载会话内容...
969
958
  </div>
970
959
 
@@ -976,16 +965,42 @@
976
965
  当前会话暂无可展示消息
977
966
  </div>
978
967
 
968
+ <div v-else-if="sessionPreviewRenderEnabled && !activeSessionVisibleMessages.length" class="session-preview-empty">
969
+ <span>正在渲染会话内容...</span>
970
+ <button class="btn-session-refresh" @click="primeSessionPreviewMessageRender" :disabled="sessionDetailLoading">
971
+ 重新渲染
972
+ </button>
973
+ </div>
974
+
975
+ <div v-else-if="!sessionPreviewRenderEnabled" class="session-preview-empty">
976
+ 正在准备会话内容...
977
+ </div>
978
+
979
979
  <div v-else class="session-preview-body">
980
980
  <div class="session-preview-messages">
981
981
  <div v-if="activeSessionDetailClipped" class="session-item-sub session-item-wrap">
982
982
  仅展示最近 {{ activeSessionMessages.length }} 条消息。
983
983
  </div>
984
984
  <div
985
- v-for="(msg, idx) in activeSessionMessages"
985
+ v-if="canLoadMoreSessionMessages"
986
+ class="session-item-sub session-item-wrap"
987
+ style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
988
+ <span>已显示 {{ activeSessionVisibleMessages.length }} / {{ activeSessionMessages.length }} 条</span>
989
+ <button class="btn-session-refresh" @click="loadMoreSessionMessages()" :disabled="sessionDetailLoading || sessionPreviewLoadingMore">
990
+ {{ sessionPreviewLoadingMore ? '加载中...' : ('加载更多(剩余 ' + sessionPreviewRemainingCount + ')') }}
991
+ </button>
992
+ </div>
993
+ <div
994
+ v-if="sessionPreviewLoadingMore"
995
+ class="session-item-sub session-item-wrap">
996
+ 正在加载更早消息...
997
+ </div>
998
+ <div
999
+ v-for="(msg, idx) in activeSessionVisibleMessages"
986
1000
  :key="getRecordRenderKey(msg, idx)"
1001
+ v-memo="[msg.text, msg.timestamp, msg.roleLabel, msg.normalizedRole]"
987
1002
  :data-message-key="getRecordRenderKey(msg, idx)"
988
- :ref="(el) => bindSessionMessageRef(getRecordRenderKey(msg, idx), el)"
1003
+ :ref="getSessionMessageRefBinder(getRecordRenderKey(msg, idx))"
989
1004
  :class="['session-msg', msg.normalizedRole === 'user' ? 'user' : (msg.normalizedRole === 'system' ? 'system' : 'assistant')]">
990
1005
  <div class="session-msg-header">
991
1006
  <div class="session-msg-meta">
@@ -998,11 +1013,12 @@
998
1013
  </div>
999
1014
  </div>
1000
1015
  </div>
1001
- <aside v-if="sessionTimelineNodes.length" class="session-timeline" aria-label="会话时间轴">
1016
+ <aside v-if="sessionPreviewRenderEnabled && sessionTimelineNodes.length" class="session-timeline" aria-label="会话时间轴">
1002
1017
  <div class="session-timeline-track"></div>
1003
1018
  <button
1004
1019
  v-for="node in sessionTimelineNodes"
1005
1020
  :key="'timeline-' + node.key"
1021
+ v-memo="[sessionTimelineActiveKey === node.key, node.safePercent, node.title]"
1006
1022
  type="button"
1007
1023
  :class="['session-timeline-node', { active: sessionTimelineActiveKey === node.key }]"
1008
1024
  :aria-current="sessionTimelineActiveKey === node.key ? 'true' : null"
@@ -1033,39 +1049,315 @@
1033
1049
  id="panel-settings"
1034
1050
  role="tabpanel"
1035
1051
  :aria-labelledby="'tab-settings'">
1036
- <div class="selector-section">
1037
- <div class="selector-header">
1038
- <span class="selector-title">Claude 配置</span>
1039
- </div>
1040
- <button class="btn-tool" @click="downloadClaudeDirectory" :disabled="claudeDownloadLoading">
1041
- {{ claudeDownloadLoading ? ('备份中 ' + claudeDownloadProgress + '%') : '一键备份 ~/.claude' }}
1052
+ <div class="config-subtabs settings-subtabs" role="tablist" aria-label="设置子标签">
1053
+ <button
1054
+ id="settings-tab-backup"
1055
+ role="tab"
1056
+ aria-controls="settings-panel-backup"
1057
+ :aria-selected="settingsTab === 'backup'"
1058
+ tabindex="0"
1059
+ :class="['config-subtab', { active: settingsTab === 'backup' }]"
1060
+ @click="onSettingsTabClick('backup')">
1061
+ 备份与导入
1042
1062
  </button>
1043
- <button class="btn-tool" @click="triggerClaudeImport" :disabled="claudeImportLoading">
1044
- {{ claudeImportLoading ? '导入中...' : '导入 ~/.claude 备份' }}
1063
+ <button
1064
+ id="settings-tab-trash"
1065
+ role="tab"
1066
+ aria-controls="settings-panel-trash"
1067
+ :aria-selected="settingsTab === 'trash'"
1068
+ tabindex="0"
1069
+ :class="['config-subtab', { active: settingsTab === 'trash' }]"
1070
+ @click="onSettingsTabClick('trash')">
1071
+ 回收站
1072
+ <span class="settings-tab-badge">{{ sessionTrashCount }}</span>
1045
1073
  </button>
1046
- <input
1047
- ref="claudeImportInput"
1048
- class="sr-only"
1049
- type="file"
1050
- accept=".zip"
1051
- @change="handleClaudeImportChange">
1052
- </div>
1053
- <div class="selector-section">
1054
- <div class="selector-header">
1055
- <span class="selector-title">Codex 配置</span>
1074
+ </div>
1075
+
1076
+ <div
1077
+ v-show="settingsTab === 'backup'"
1078
+ id="settings-panel-backup"
1079
+ role="tabpanel"
1080
+ aria-labelledby="settings-tab-backup">
1081
+ <div class="selector-section">
1082
+ <div class="selector-header">
1083
+ <span class="selector-title">Claude 配置</span>
1084
+ </div>
1085
+ <button class="btn-tool" @click="downloadClaudeDirectory" :disabled="claudeDownloadLoading">
1086
+ {{ claudeDownloadLoading ? ('备份中 ' + claudeDownloadProgress + '%') : '一键备份 ~/.claude' }}
1087
+ </button>
1088
+ <button class="btn-tool" @click="triggerClaudeImport" :disabled="claudeImportLoading">
1089
+ {{ claudeImportLoading ? '导入中...' : '导入 ~/.claude 备份' }}
1090
+ </button>
1091
+ <input
1092
+ ref="claudeImportInput"
1093
+ class="sr-only"
1094
+ type="file"
1095
+ accept=".zip"
1096
+ @change="handleClaudeImportChange">
1097
+ </div>
1098
+ <div class="selector-section">
1099
+ <div class="selector-header">
1100
+ <span class="selector-title">Codex 配置</span>
1101
+ </div>
1102
+ <button class="btn-tool" @click="downloadCodexDirectory" :disabled="codexDownloadLoading">
1103
+ {{ codexDownloadLoading ? ('备份中 ' + codexDownloadProgress + '%') : '一键备份 ~/.codex' }}
1104
+ </button>
1105
+ <button class="btn-tool" @click="triggerCodexImport" :disabled="codexImportLoading">
1106
+ {{ codexImportLoading ? '导入中...' : '导入 ~/.codex 备份' }}
1107
+ </button>
1108
+ <input
1109
+ ref="codexImportInput"
1110
+ class="sr-only"
1111
+ type="file"
1112
+ accept=".zip"
1113
+ @change="handleCodexImportChange">
1114
+ </div>
1115
+ </div>
1116
+
1117
+ <div
1118
+ v-show="settingsTab === 'trash'"
1119
+ id="settings-panel-trash"
1120
+ role="tabpanel"
1121
+ aria-labelledby="settings-tab-trash">
1122
+ <div class="selector-section">
1123
+ <div class="selector-header settings-tab-header">
1124
+ <div>
1125
+ <span class="selector-title">会话回收站</span>
1126
+ </div>
1127
+ <div class="settings-tab-actions">
1128
+ <button class="btn-tool btn-tool-compact" @click="loadSessionTrash({ forceRefresh: true })" :disabled="sessionTrashLoading || sessionTrashClearing">
1129
+ {{ sessionTrashLoading ? '刷新中...' : '刷新列表' }}
1130
+ </button>
1131
+ <button class="btn-tool btn-tool-compact" @click="clearSessionTrash" :disabled="sessionTrashClearing || sessionTrashLoading || !(Number(sessionTrashCount) > 0)">
1132
+ {{ sessionTrashClearing ? '清空中...' : '清空回收站' }}
1133
+ </button>
1134
+ </div>
1135
+ </div>
1136
+
1137
+ <div v-if="getSessionTrashViewState() === 'loading'" class="session-empty">
1138
+ 正在加载回收站...
1139
+ </div>
1140
+ <div v-else-if="getSessionTrashViewState() === 'empty'" class="session-empty">
1141
+ 回收站为空
1142
+ </div>
1143
+ <div v-else-if="getSessionTrashViewState() === 'retry'" class="session-empty">
1144
+ 回收站列表加载失败,请刷新重试
1145
+ </div>
1146
+ <div v-else class="trash-list">
1147
+ <div v-for="item in visibleSessionTrashItems" :key="item.trashId" class="trash-item session-item session-card">
1148
+ <div class="trash-item-header session-item-header">
1149
+ <div class="trash-item-main">
1150
+ <div class="trash-item-mainline">
1151
+ <div class="trash-item-title">{{ item.title || item.sessionId }}</div>
1152
+ <span class="session-count-badge">{{ item.messageCount ?? 0 }}</span>
1153
+ </div>
1154
+ <div class="trash-item-meta session-item-meta">
1155
+ <span class="session-source">{{ item.sourceLabel }}</span>
1156
+ </div>
1157
+ </div>
1158
+ <div class="trash-item-side">
1159
+ <div class="trash-item-actions session-item-actions">
1160
+ <button class="btn-mini" @click="restoreSessionTrash(item)" :disabled="sessionTrashLoading || sessionTrashClearing || isSessionTrashActionBusy(item)">
1161
+ {{ sessionTrashRestoring[getSessionTrashActionKey(item)] ? '恢复中...' : '恢复' }}
1162
+ </button>
1163
+ <button class="btn-mini delete" @click="purgeSessionTrash(item)" :disabled="sessionTrashLoading || sessionTrashClearing || isSessionTrashActionBusy(item)">
1164
+ {{ sessionTrashPurging[getSessionTrashActionKey(item)] ? '删除中...' : '彻底删除' }}
1165
+ </button>
1166
+ </div>
1167
+ <div class="trash-item-time session-item-time">{{ item.deletedAt || item.updatedAt || 'unknown time' }}</div>
1168
+ </div>
1169
+ </div>
1170
+ <div v-if="item.cwd" class="trash-item-path session-item-sub session-item-wrap">
1171
+ <span class="trash-item-label">工作区</span>
1172
+ <span>{{ item.cwd }}</span>
1173
+ </div>
1174
+ <div class="trash-item-path session-item-sub session-item-wrap">
1175
+ <span class="trash-item-label">原文件</span>
1176
+ <span>{{ item.originalFilePath }}</span>
1177
+ </div>
1178
+ </div>
1179
+ <div v-if="sessionTrashHasMoreItems" class="trash-list-footer">
1180
+ <button class="btn-tool btn-tool-compact" @click="loadMoreSessionTrashItems" :disabled="sessionTrashLoading || sessionTrashClearing">
1181
+ 加载更多(剩余 {{ sessionTrashHiddenCount }} 项)
1182
+ </button>
1183
+ </div>
1184
+ </div>
1185
+ </div>
1186
+ </div>
1187
+ </div>
1188
+
1189
+ <div
1190
+ v-show="mainTab === 'market'"
1191
+ class="mode-content"
1192
+ id="panel-market"
1193
+ role="tabpanel"
1194
+ aria-labelledby="tab-market">
1195
+ <div class="selector-section market-overview-section">
1196
+ <div class="selector-header market-overview-header">
1197
+ <div>
1198
+ <span class="selector-title">Skills 市场概览</span>
1199
+ <div class="skills-panel-note">当前市场页只保留本地 skills 能力:切换 Codex / Claude Code 安装目标,查看已安装项,执行跨应用导入与 ZIP 分发。</div>
1200
+ </div>
1201
+ <div class="settings-tab-actions market-header-actions">
1202
+ <button class="btn-tool btn-tool-compact" @click="loadSkillsMarketOverview({ forceRefresh: true, silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
1203
+ {{ skillsMarketLoading ? '刷新中...' : '刷新概览' }}
1204
+ </button>
1205
+ <button class="btn-tool btn-tool-compact" @click="openSkillsManager" :disabled="loading || !!initError || skillsMarketBusy">
1206
+ 打开 Skills 管理
1207
+ </button>
1208
+ </div>
1209
+ </div>
1210
+
1211
+ <div class="market-target-switch" role="group" aria-label="选择 Skills 安装目标">
1212
+ <button
1213
+ type="button"
1214
+ :class="['market-target-chip', { active: skillsTargetApp === 'codex' }]"
1215
+ :aria-pressed="skillsTargetApp === 'codex'"
1216
+ :disabled="loading || !!initError || skillsMarketBusy"
1217
+ @click="setSkillsTargetApp('codex', { silent: false })">
1218
+ Codex
1219
+ </button>
1220
+ <button
1221
+ type="button"
1222
+ :class="['market-target-chip', { active: skillsTargetApp === 'claude' }]"
1223
+ :aria-pressed="skillsTargetApp === 'claude'"
1224
+ :disabled="loading || !!initError || skillsMarketBusy"
1225
+ @click="setSkillsTargetApp('claude', { silent: false })">
1226
+ Claude Code
1227
+ </button>
1228
+ </div>
1229
+
1230
+ <div class="skills-root-box market-root-box">{{ skillsRootPath || skillsDefaultRootPath }}</div>
1231
+
1232
+ <div class="skills-summary-strip market-summary-strip">
1233
+ <div class="skills-summary-item">
1234
+ <span class="skills-summary-label">安装目标</span>
1235
+ <strong class="skills-summary-value">{{ skillsTargetLabel }}</strong>
1236
+ </div>
1237
+ <div class="skills-summary-item">
1238
+ <span class="skills-summary-label">本地总数</span>
1239
+ <strong class="skills-summary-value">{{ skillsList.length }}</strong>
1240
+ </div>
1241
+ <div class="skills-summary-item">
1242
+ <span class="skills-summary-label">含 SKILL.md</span>
1243
+ <strong class="skills-summary-value">{{ skillsConfiguredCount }}</strong>
1244
+ </div>
1245
+ <div class="skills-summary-item">
1246
+ <span class="skills-summary-label">缺少 SKILL.md</span>
1247
+ <strong class="skills-summary-value">{{ skillsMissingSkillFileCount }}</strong>
1248
+ </div>
1249
+ <div class="skills-summary-item">
1250
+ <span class="skills-summary-label">可导入</span>
1251
+ <strong class="skills-summary-value">{{ skillsImportList.length }}</strong>
1252
+ </div>
1253
+ <div class="skills-summary-item">
1254
+ <span class="skills-summary-label">可直接导入</span>
1255
+ <strong class="skills-summary-value">{{ skillsImportConfiguredCount }}</strong>
1256
+ </div>
1257
+ </div>
1258
+ </div>
1259
+
1260
+ <div class="market-grid">
1261
+ <div class="skills-panel market-panel">
1262
+ <div class="skills-panel-header">
1263
+ <div class="skills-panel-title-wrap">
1264
+ <div class="skills-panel-title">已安装 Skills</div>
1265
+ <div class="skills-panel-note">展示当前已落地到 <code>{{ skillsRootPath || skillsDefaultRootPath }}</code> 的前 6 个目录,可继续进入管理弹窗做筛选、导出和删除。</div>
1266
+ </div>
1267
+ <button class="btn-mini" @click="refreshSkillsList({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
1268
+ {{ skillsLoading ? '刷新中...' : '刷新本地' }}
1269
+ </button>
1270
+ </div>
1271
+ <div v-if="skillsLoading && !skillsMarketLocalLoadedOnce" class="skills-empty-state">正在加载本地 Skills...</div>
1272
+ <div v-else-if="skillsList.length === 0" class="skills-empty-state">当前还没有已安装 skill,可通过 ZIP 或跨应用导入补充。</div>
1273
+ <div v-else class="market-preview-list">
1274
+ <div v-for="skill in skillsMarketInstalledPreview" :key="'market-local-' + skill.name" class="market-preview-item">
1275
+ <div class="market-preview-main">
1276
+ <div class="market-preview-title">{{ skill.displayName || skill.name }}</div>
1277
+ <div class="market-preview-meta">{{ skill.description || skill.path }}</div>
1278
+ </div>
1279
+ <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
1280
+ {{ skill.hasSkillFile ? '已验证' : '待补 SKILL.md' }}
1281
+ </span>
1282
+ </div>
1283
+ </div>
1284
+ </div>
1285
+
1286
+ <div class="skills-panel market-panel">
1287
+ <div class="skills-panel-header">
1288
+ <div class="skills-panel-title-wrap">
1289
+ <div class="skills-panel-title">可导入来源</div>
1290
+ <div class="skills-panel-note">扫描其他应用下未托管的 skill,先确认来源和目录,再批量导入到当前 {{ skillsTargetLabel }} skills 目录。</div>
1291
+ </div>
1292
+ <button class="btn-mini" @click="scanImportableSkills({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
1293
+ {{ skillsScanningImports ? '扫描中...' : '扫描来源' }}
1294
+ </button>
1295
+ </div>
1296
+ <div v-if="skillsScanningImports && !skillsMarketImportLoadedOnce" class="skills-empty-state">正在扫描可导入 skill...</div>
1297
+ <div v-else-if="skillsImportList.length === 0" class="skills-empty-state">还没有扫描到可导入 skill,可点击“扫描来源”重新读取。</div>
1298
+ <div v-else class="market-preview-list">
1299
+ <div v-for="skill in skillsMarketImportPreview" :key="'market-import-' + buildSkillImportKey(skill)" class="market-preview-item">
1300
+ <div class="market-preview-main">
1301
+ <div class="market-preview-title">{{ skill.displayName || skill.name }}</div>
1302
+ <div class="market-preview-meta">{{ skill.sourceLabel }} · {{ skill.sourcePath }}</div>
1303
+ </div>
1304
+ <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
1305
+ {{ skill.hasSkillFile ? '可直接导入' : '缺少 SKILL.md' }}
1306
+ </span>
1307
+ </div>
1308
+ </div>
1309
+ </div>
1310
+
1311
+ <div class="skills-panel market-panel market-actions-panel">
1312
+ <div class="skills-panel-header">
1313
+ <div class="skills-panel-title-wrap">
1314
+ <div class="skills-panel-title">分发入口</div>
1315
+ <div class="skills-panel-note">市场页聚焦本地落地:本地管理、跨应用导入、ZIP 分发,全部作用到当前安装目标。</div>
1316
+ </div>
1317
+ </div>
1318
+ <div class="market-action-grid">
1319
+ <button class="market-action-card" @click="openSkillsManager" :disabled="loading || !!initError || skillsMarketBusy">
1320
+ <span class="market-action-title">本地 Skills 管理</span>
1321
+ <span class="market-action-copy">查看、筛选、导出、删除当前 {{ skillsTargetLabel }} 已安装 skills</span>
1322
+ </button>
1323
+ <button class="market-action-card" @click="scanImportableSkills({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
1324
+ <span class="market-action-title">跨应用导入</span>
1325
+ <span class="market-action-copy">扫描其他应用目录并导入到当前 {{ skillsTargetLabel }}</span>
1326
+ </button>
1327
+ <button class="market-action-card" @click="triggerSkillsZipImport" :disabled="loading || !!initError || skillsMarketBusy">
1328
+ <span class="market-action-title">ZIP 导入</span>
1329
+ <span class="market-action-copy">从压缩包分发并安装到当前目标宿主</span>
1330
+ </button>
1331
+ </div>
1332
+ </div>
1333
+
1334
+ <div class="skills-panel market-panel market-panel-wide">
1335
+ <div class="skills-panel-header">
1336
+ <div class="skills-panel-title-wrap">
1337
+ <div class="skills-panel-title">市场说明</div>
1338
+ </div>
1339
+ </div>
1340
+ <div class="market-preview-list">
1341
+ <div class="market-preview-item">
1342
+ <div class="market-preview-main">
1343
+ <div class="market-preview-title">目标宿主切换</div>
1344
+ <div class="market-preview-meta">在 Codex 和 Claude Code 之间切换后,后续扫描、导入、导出、删除都会落到当前 {{ skillsTargetLabel }} 目录。</div>
1345
+ </div>
1346
+ </div>
1347
+ <div class="market-preview-item">
1348
+ <div class="market-preview-main">
1349
+ <div class="market-preview-title">跨应用导入</div>
1350
+ <div class="market-preview-meta">扫描 `Codex`、`Claude Code` 与 `Agents` 目录里的未托管 skills,筛选后批量导入到当前宿主。</div>
1351
+ </div>
1352
+ </div>
1353
+ <div class="market-preview-item">
1354
+ <div class="market-preview-main">
1355
+ <div class="market-preview-title">ZIP 分发</div>
1356
+ <div class="market-preview-meta">通过压缩包在不同环境间分发技能目录,保持本地可控,不依赖外部目录服务。</div>
1357
+ </div>
1358
+ </div>
1359
+ </div>
1056
1360
  </div>
1057
- <button class="btn-tool" @click="downloadCodexDirectory" :disabled="codexDownloadLoading">
1058
- {{ codexDownloadLoading ? ('备份中 ' + codexDownloadProgress + '%') : '一键备份 ~/.codex' }}
1059
- </button>
1060
- <button class="btn-tool" @click="triggerCodexImport" :disabled="codexImportLoading">
1061
- {{ codexImportLoading ? '导入中...' : '导入 ~/.codex 备份' }}
1062
- </button>
1063
- <input
1064
- ref="codexImportInput"
1065
- class="sr-only"
1066
- type="file"
1067
- accept=".zip"
1068
- @change="handleCodexImportChange">
1069
1361
  </div>
1070
1362
  </div>
1071
1363
 
@@ -1134,8 +1426,6 @@
1134
1426
  <span :class="['value', 'tone-' + inspectorHealthTone]">{{ inspectorHealthStatus }}</span>
1135
1427
  <span class="key">模型加载</span>
1136
1428
  <span class="value">{{ inspectorModelLoadStatus }}</span>
1137
- <span class="key">代理状态</span>
1138
- <span class="value">{{ inspectorProxyStatus }}</span>
1139
1429
  </div>
1140
1430
  </section>
1141
1431
  </aside>
@@ -1540,7 +1830,7 @@
1540
1830
  <div class="structured-card">
1541
1831
  <div class="structured-card-title">Providers(只读)</div>
1542
1832
  <div v-if="openclawProviders.length === 0" class="form-hint">
1543
- 未发现 providers 配置(可能使用内置 provider 或 auth profiles)。
1833
+ 未发现 providers 配置(可能使用环境变量或外部配置)。
1544
1834
  </div>
1545
1835
  <div v-else class="provider-list">
1546
1836
  <div class="provider-item" v-for="(provider, index) in openclawProviders" :key="provider.key + '-' + provider.source + '-' + index">
@@ -1669,24 +1959,70 @@
1669
1959
  </div>
1670
1960
  </div>
1671
1961
 
1962
+
1672
1963
  <div class="form-group">
1673
1964
  <label class="form-label">AGENTS.md 内容</label>
1965
+ <div
1966
+ v-if="!agentsLoading && (hasAgentsContentChanged() || agentsDiffVisible)"
1967
+ class="agents-diff-save-alert">
1968
+ {{ agentsDiffVisible ? '预览模式:当前改动尚未保存,只有点击“应用”后才会写入文件。' : '检测到未保存改动:关闭页面或应用前请先保存。' }}
1969
+ </div>
1970
+ <div v-if="agentsDiffVisible">
1971
+ <div
1972
+ v-if="!agentsDiffLoading && !agentsDiffError && !agentsDiffTruncated && (agentsDiffStats.added || agentsDiffStats.removed)"
1973
+ class="agents-diff-summary">
1974
+ <span class="agents-diff-stat add">+{{ agentsDiffStats.added }}</span>
1975
+ <span class="agents-diff-stat del">-{{ agentsDiffStats.removed }}</span>
1976
+ </div>
1977
+ <div v-if="agentsDiffLoading" class="state-message">生成差异中...</div>
1978
+ <div v-else-if="agentsDiffError" class="state-message error">{{ agentsDiffError }}</div>
1979
+ <div v-else-if="agentsDiffTruncated" class="agents-diff-empty">内容过大,已跳过逐行差异预览</div>
1980
+ <div v-else-if="!agentsDiffHasChanges" class="agents-diff-empty">未检测到改动</div>
1981
+ <div v-else class="agents-diff-view agents-diff-editor">
1982
+ <div
1983
+ v-for="(line, index) in agentsDiffLines"
1984
+ :key="line.key || (line.type + '-' + index)"
1985
+ :class="['agents-diff-line', line.type]">
1986
+ <span class="agents-diff-line-sign">
1987
+ {{ line.type === 'add' ? '+' : (line.type === 'del' ? '-' : ' ') }}
1988
+ </span>
1989
+ <span class="agents-diff-line-text">{{ line.value }}</span>
1990
+ </div>
1991
+ </div>
1992
+ </div>
1674
1993
  <textarea
1994
+ v-else
1675
1995
  v-model="agentsContent"
1676
1996
  class="form-input template-editor"
1677
1997
  spellcheck="false"
1678
1998
  :readonly="agentsLoading"
1999
+ @input="onAgentsContentInput"
1679
2000
  placeholder="在这里编辑 AGENTS.md 内容"></textarea>
1680
2001
  <div class="template-editor-warning">
1681
2002
  {{ agentsModalHint }}
2003
+ <div class="agents-diff-hint">快捷键:Esc(差异预览时返回编辑,编辑时关闭窗口)。</div>
2004
+ <div v-if="!agentsDiffVisible" class="agents-diff-hint">保存需两步:先点击“确认”预览差异,再点击“应用”保存。</div>
2005
+ <div v-else-if="agentsDiffLoading || agentsSaving" class="agents-diff-hint">正在生成差异或应用中,操作暂不可用。</div>
2006
+ <div v-else-if="agentsDiffError" class="agents-diff-hint">差异预览失败,请返回编辑后重试。</div>
2007
+ <div v-else-if="!agentsDiffHasChanges" class="agents-diff-hint">未检测到改动,可返回编辑继续修改或取消退出。</div>
2008
+ <div v-else-if="agentsDiffTruncated" class="agents-diff-hint">内容过大,已跳过预览,可点击“应用”保存或“返回编辑”继续修改。</div>
2009
+ <div v-else class="agents-diff-hint">当前为预览模式,可点击“应用”保存或“返回编辑”继续修改。</div>
1682
2010
  </div>
1683
2011
  </div>
2012
+
1684
2013
  </div>
1685
2014
 
1686
2015
  <div class="btn-group modal-editor-footer">
1687
- <button class="btn btn-cancel" @click="closeAgentsModal">取消</button>
1688
- <button class="btn btn-confirm" @click="applyAgentsContent" :disabled="agentsSaving || agentsLoading">
1689
- {{ agentsSaving ? '保存中...' : '保存' }}
2016
+ <button class="btn btn-cancel" @click="closeAgentsModal" :disabled="agentsSaving || agentsDiffLoading">取消</button>
2017
+ <button
2018
+ v-if="agentsDiffVisible"
2019
+ class="btn"
2020
+ @click="resetAgentsDiffState"
2021
+ :disabled="agentsSaving || agentsDiffLoading">
2022
+ 返回编辑
2023
+ </button>
2024
+ <button class="btn btn-confirm" @click="applyAgentsContent" :disabled="agentsSaving || agentsLoading || agentsDiffLoading || (agentsDiffVisible && !agentsDiffHasChanges)">
2025
+ {{ agentsSaving ? (agentsDiffVisible ? '应用中...' : '确认中...') : (agentsDiffVisible ? '应用' : '确认') }}
1690
2026
  </button>
1691
2027
  </div>
1692
2028
  </div>
@@ -1697,9 +2033,27 @@
1697
2033
  <div class="modal-header skills-modal-header">
1698
2034
  <div>
1699
2035
  <div class="modal-title">Skills 管理</div>
1700
- <div class="skills-modal-subtitle">集中管理本地技能目录,支持检索筛选、多选删除、跨应用导入、ZIP 导入与导出。</div>
2036
+ <div class="skills-modal-subtitle">集中管理当前宿主的本地技能目录,支持检索筛选、多选删除、跨应用导入、ZIP 导入与导出。</div>
1701
2037
  </div>
1702
2038
  <div class="modal-header-actions skills-modal-actions">
2039
+ <div class="market-target-switch market-target-switch-compact" role="group" aria-label="选择 Skills 管理目标">
2040
+ <button
2041
+ type="button"
2042
+ :class="['market-target-chip', { active: skillsTargetApp === 'codex' }]"
2043
+ :aria-pressed="skillsTargetApp === 'codex'"
2044
+ :disabled="loading || !!initError || skillsMarketBusy"
2045
+ @click="setSkillsTargetApp('codex', { silent: false })">
2046
+ Codex
2047
+ </button>
2048
+ <button
2049
+ type="button"
2050
+ :class="['market-target-chip', { active: skillsTargetApp === 'claude' }]"
2051
+ :aria-pressed="skillsTargetApp === 'claude'"
2052
+ :disabled="loading || !!initError || skillsMarketBusy"
2053
+ @click="setSkillsTargetApp('claude', { silent: false })">
2054
+ Claude Code
2055
+ </button>
2056
+ </div>
1703
2057
  <button class="btn-mini" @click="refreshSkillsList({ silent: false })" :disabled="skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting || skillsZipImporting || skillsExporting">
1704
2058
  {{ skillsLoading ? '刷新中...' : '刷新' }}
1705
2059
  </button>
@@ -1707,11 +2061,15 @@
1707
2061
  </div>
1708
2062
 
1709
2063
  <div class="form-group skills-root-group">
1710
- <label class="form-label">Skills 目录</label>
1711
- <div class="skills-root-box">{{ skillsRootPath || '~/.codex/skills' }}</div>
2064
+ <label class="form-label">Skills 目录({{ skillsTargetLabel }})</label>
2065
+ <div class="skills-root-box">{{ skillsRootPath || skillsDefaultRootPath }}</div>
1712
2066
  </div>
1713
2067
 
1714
2068
  <div class="skills-summary-strip">
2069
+ <div class="skills-summary-item">
2070
+ <span class="skills-summary-label">安装目标</span>
2071
+ <strong class="skills-summary-value">{{ skillsTargetLabel }}</strong>
2072
+ </div>
1715
2073
  <div class="skills-summary-item">
1716
2074
  <span class="skills-summary-label">本地总数</span>
1717
2075
  <strong class="skills-summary-value">{{ skillsList.length }}</strong>
@@ -1734,7 +2092,7 @@
1734
2092
  <div class="skills-panel-header">
1735
2093
  <div class="skills-panel-title-wrap">
1736
2094
  <div class="skills-panel-title">本地 Skills</div>
1737
- <div class="skills-panel-note">支持关键词检索与状态筛选,勾选后可批量删除。</div>
2095
+ <div class="skills-panel-note">支持关键词检索与状态筛选,勾选后可批量删除当前 {{ skillsTargetLabel }} 本地 skill。</div>
1738
2096
  </div>
1739
2097
  <button
1740
2098
  class="btn-mini"
@@ -1793,8 +2151,8 @@
1793
2151
  <div class="skills-panel skills-import-block">
1794
2152
  <div class="skills-panel-header">
1795
2153
  <div class="skills-panel-title-wrap">
1796
- <div class="skills-import-title">跨应用导入(对齐 cc-switch 能力)</div>
1797
- <div class="skills-panel-note">从其他应用扫描并导入未托管技能,支持多选批量导入。</div>
2154
+ <div class="skills-import-title">跨应用导入</div>
2155
+ <div class="skills-panel-note">从其他应用扫描并导入未托管技能,支持多选批量导入到当前 {{ skillsTargetLabel }}。</div>
1798
2156
  </div>
1799
2157
  <button class="btn-mini" @click="scanImportableSkills" :disabled="skillsLoading || skillsDeleting || skillsScanningImports || skillsImporting || skillsZipImporting || skillsExporting">
1800
2158
  {{ skillsScanningImports ? '扫描中...' : '扫描可导入' }}
@@ -1845,12 +2203,35 @@
1845
2203
  {{ skillsDeleting ? '删除中...' : '删除选中' }}
1846
2204
  </button>
1847
2205
  </div>
1848
- <input
1849
- ref="skillsZipImportInput"
1850
- type="file"
1851
- accept=".zip,application/zip"
1852
- style="display:none"
1853
- @change="handleSkillsZipImportChange">
2206
+ </div>
2207
+ </div>
2208
+
2209
+ <input
2210
+ ref="skillsZipImportInput"
2211
+ type="file"
2212
+ accept=".zip,application/zip"
2213
+ style="display:none"
2214
+ @change="handleSkillsZipImportChange">
2215
+
2216
+ <div v-if="showConfirmDialog" class="modal-overlay" @click.self="closeConfirmDialog">
2217
+ <div
2218
+ class="modal confirm-dialog"
2219
+ role="alertdialog"
2220
+ aria-modal="true"
2221
+ aria-describedby="confirm-dialog-message"
2222
+ :aria-labelledby="confirmDialogTitle ? 'confirm-dialog-title' : null"
2223
+ :aria-label="confirmDialogTitle ? null : '确认操作'">
2224
+ <div id="confirm-dialog-title" class="modal-title">{{ confirmDialogTitle }}</div>
2225
+ <div id="confirm-dialog-message" class="confirm-dialog-message">{{ confirmDialogMessage }}</div>
2226
+ <div class="btn-group confirm-dialog-actions">
2227
+ <button class="btn btn-cancel" @click="closeConfirmDialog">{{ confirmDialogCancelText }}</button>
2228
+ <button
2229
+ :class="['btn', 'btn-confirm', confirmDialogDanger ? 'btn-danger' : '']"
2230
+ :disabled="isConfirmDialogDisabled()"
2231
+ @click="resolveConfirmDialog(true)">
2232
+ {{ confirmDialogConfirmText }}
2233
+ </button>
2234
+ </div>
1854
2235
  </div>
1855
2236
  </div>
1856
2237