codexmate 0.0.34 → 0.0.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/cli.js +74 -61
  2. package/package.json +2 -1
  3. package/web-ui/app.js +33 -2
  4. package/web-ui/index.html +1 -1
  5. package/web-ui/logic.sessions.mjs +6 -5
  6. package/web-ui/modules/app.computed.dashboard.mjs +4 -0
  7. package/web-ui/modules/app.computed.session.mjs +147 -6
  8. package/web-ui/modules/app.methods.claude-config.mjs +4 -0
  9. package/web-ui/modules/app.methods.navigation.mjs +32 -16
  10. package/web-ui/modules/app.methods.session-browser.mjs +7 -0
  11. package/web-ui/modules/app.methods.session-trash.mjs +30 -0
  12. package/web-ui/modules/i18n.dict.mjs +5 -0
  13. package/web-ui/modules/sessions-filters-url.mjs +65 -12
  14. package/web-ui/modules/skills.methods.mjs +31 -0
  15. package/web-ui/partials/index/layout-header.html +17 -12
  16. package/web-ui/partials/index/panel-config-claude.html +5 -3
  17. package/web-ui/partials/index/panel-config-codex.html +1 -1
  18. package/web-ui/partials/index/panel-market.html +76 -149
  19. package/web-ui/partials/index/panel-sessions.html +2 -2
  20. package/web-ui/partials/index/panel-settings.html +4 -2
  21. package/web-ui/partials/index/panel-usage.html +115 -68
  22. package/web-ui/res/vue.runtime.global.prod.js +7 -0
  23. package/web-ui/res/web-ui-render.precompiled.js +7274 -0
  24. package/web-ui/session-helpers.mjs +15 -4
  25. package/web-ui/source-bundle.cjs +73 -1
  26. package/web-ui/styles/base-theme.css +10 -0
  27. package/web-ui/styles/layout-shell.css +66 -27
  28. package/web-ui/styles/navigation-panels.css +8 -0
  29. package/web-ui/styles/responsive.css +50 -9
  30. package/web-ui/styles/sessions-usage.css +336 -319
  31. package/web-ui/styles/skills-market.css +294 -0
  32. package/web-ui/styles/titles-cards.css +14 -0
@@ -218,6 +218,36 @@ export function createSessionTrashMethods(options = {}) {
218
218
  await this.switchSettingsTab(tab);
219
219
  },
220
220
 
221
+ async onSettingsTabKeydown(event, tab) {
222
+ if (!event) {
223
+ return;
224
+ }
225
+ const tabs = ['general', 'data'];
226
+ const currentTab = this.normalizeSettingsTab(tab || this.settingsTab);
227
+ const currentIndex = Math.max(0, tabs.indexOf(currentTab));
228
+ let nextIndex = currentIndex;
229
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
230
+ nextIndex = (currentIndex + 1) % tabs.length;
231
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
232
+ nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
233
+ } else if (event.key === 'Home') {
234
+ nextIndex = 0;
235
+ } else if (event.key === 'End') {
236
+ nextIndex = tabs.length - 1;
237
+ } else {
238
+ return;
239
+ }
240
+ event.preventDefault();
241
+ const nextTab = tabs[nextIndex];
242
+ await this.switchSettingsTab(nextTab);
243
+ const target = typeof document !== 'undefined'
244
+ ? document.getElementById(`settings-tab-${nextTab}`)
245
+ : null;
246
+ if (target && typeof target.focus === 'function') {
247
+ target.focus();
248
+ }
249
+ },
250
+
221
251
  async switchSettingsTab(tab, options = {}) {
222
252
  const nextTab = this.normalizeSettingsTab(tab);
223
253
  this.settingsTab = nextTab;
@@ -532,6 +532,7 @@ const DICT = Object.freeze({
532
532
  'sessions.loadingList': '会话加载中...',
533
533
  'sessions.empty': '暂无可用会话记录',
534
534
  'sessions.unknownTime': '未知时间',
535
+
535
536
  'sessions.query.placeholder.enabled': '关键词检索(支持 Codex/Claude/Gemini/CodeBuddy,例:claude code)',
536
537
  'sessions.query.placeholder.disabled': '当前来源暂不支持关键词检索',
537
538
  'sessions.pin': '置顶',
@@ -1602,6 +1603,8 @@ const DICT = Object.freeze({
1602
1603
  'sessions.loadingList': 'セッション一覧を読み込み中...',
1603
1604
  'sessions.empty': 'セッションがありません',
1604
1605
  'sessions.unknownTime': '不明な時間',
1606
+
1607
+
1605
1608
  'sessions.query.placeholder.enabled': 'セッションを検索...',
1606
1609
  'sessions.query.placeholder.disabled': '現在のソースでは検索は利用できません',
1607
1610
  'sessions.pin': 'ピン留め',
@@ -2658,6 +2661,8 @@ const DICT = Object.freeze({
2658
2661
  'sessions.loadingList': 'Loading sessions...',
2659
2662
  'sessions.empty': 'No sessions found',
2660
2663
  'sessions.unknownTime': 'unknown time',
2664
+
2665
+
2661
2666
  'sessions.query.placeholder.enabled': 'Search keywords (Codex/Claude/Gemini/CodeBuddy, e.g. claude code)',
2662
2667
  'sessions.query.placeholder.disabled': 'Keyword search is not available for this source',
2663
2668
  'sessions.pin': 'Pin',
@@ -55,20 +55,76 @@ export function applySessionsFilterUrlState(vm, state) {
55
55
  }
56
56
  }
57
57
 
58
+ export function canonicalizeWebUiUrl(url) {
59
+ if (!url || typeof url !== 'object') return url;
60
+ if (url.pathname === '/web-ui/index.html') {
61
+ url.pathname = '/';
62
+ }
63
+ return url;
64
+ }
65
+
66
+ function canonicalizeWebUiHistoryUrl(value) {
67
+ if (typeof window === 'undefined' || !window.location) return value;
68
+ if (typeof value === 'undefined' || value === null) return value;
69
+ try {
70
+ const url = canonicalizeWebUiUrl(new URL(String(value), window.location.href));
71
+ return url && url.pathname === '/' ? url.href : value;
72
+ } catch (_) {
73
+ return value;
74
+ }
75
+ }
76
+
77
+ export function normalizeCurrentWebUiUrl() {
78
+ try {
79
+ const url = canonicalizeWebUiUrl(new URL(window.location.href));
80
+ if (url && url.href !== window.location.href) {
81
+ window.history.replaceState(null, '', url.href);
82
+ }
83
+ return url;
84
+ } catch (_) {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function installWebUiUrlCanonicalization() {
90
+ if (typeof window === 'undefined' || !window.history) return false;
91
+ if (window.__codexmateWebUiUrlCanonicalizationInstalled) {
92
+ normalizeCurrentWebUiUrl();
93
+ return true;
94
+ }
95
+ try {
96
+ const originalReplaceState = window.history.replaceState;
97
+ const originalPushState = window.history.pushState;
98
+ if (typeof originalReplaceState === 'function') {
99
+ window.history.replaceState = function replaceState(state, title, url) {
100
+ return originalReplaceState.call(this, state, title, canonicalizeWebUiHistoryUrl(url));
101
+ };
102
+ }
103
+ if (typeof originalPushState === 'function') {
104
+ window.history.pushState = function pushState(state, title, url) {
105
+ return originalPushState.call(this, state, title, canonicalizeWebUiHistoryUrl(url));
106
+ };
107
+ }
108
+ window.__codexmateWebUiUrlCanonicalizationInstalled = true;
109
+ normalizeCurrentWebUiUrl();
110
+ return true;
111
+ } catch (_) {
112
+ normalizeCurrentWebUiUrl();
113
+ return false;
114
+ }
115
+ }
116
+
58
117
  export function buildSessionsFilterShareUrl(vm) {
59
118
  try {
60
- const url = new URL(window.location.href);
61
- if (url.pathname === '/session') return '';
119
+ // 使用干净的根路径作为基础 URL,避免把 /web-ui/index.html 或 /session 带进分享链接。
120
+ const baseUrl = window.location.origin + '/';
121
+ const url = canonicalizeWebUiUrl(new URL(baseUrl));
122
+ url.searchParams.set('tab', 'sessions');
62
123
  url.searchParams.set('s_source', String(vm.sessionFilterSource || 'all'));
63
124
  if (vm.sessionPathFilter) url.searchParams.set('s_path', String(vm.sessionPathFilter || ''));
64
- else url.searchParams.delete('s_path');
65
125
  if (vm.sessionQuery && isSessionQueryEnabled(vm.sessionFilterSource)) url.searchParams.set('s_query', String(vm.sessionQuery || ''));
66
- else url.searchParams.delete('s_query');
67
126
  if (vm.sessionRoleFilter && vm.sessionRoleFilter !== 'all') url.searchParams.set('s_role', String(vm.sessionRoleFilter || 'all'));
68
- else url.searchParams.delete('s_role');
69
127
  if (vm.sessionTimePreset && vm.sessionTimePreset !== 'all') url.searchParams.set('s_time', String(vm.sessionTimePreset || 'all'));
70
- else url.searchParams.delete('s_time');
71
- url.searchParams.set('tab', 'sessions');
72
128
  return url.toString();
73
129
  } catch (_) {
74
130
  return '';
@@ -76,10 +132,7 @@ export function buildSessionsFilterShareUrl(vm) {
76
132
  }
77
133
 
78
134
  export function syncSessionsFilterUrl(vm) {
79
- const url = buildSessionsFilterShareUrl(vm);
80
- if (!url) return;
81
- try {
82
- window.history.replaceState(null, '', url);
83
- } catch (_) {}
135
+ // URL 保持静态,不同步状态到 URL
136
+ // 所有状态通过 localStorage 管理
84
137
  }
85
138
 
@@ -477,6 +477,37 @@ export function createSkillsMethods({ api }) {
477
477
  this.skillsDeleting = false;
478
478
  await this.scanImportableSkills({ silent: true });
479
479
  }
480
+ },
481
+
482
+ openSkillsMenu() {
483
+ // Open skills manager modal as menu
484
+ this.openSkillsManager();
485
+ },
486
+
487
+ async importSingleSkill(skill) {
488
+ if (this.skillsImporting || this.skillsZipImporting) return;
489
+ const key = this.buildSkillImportKey(skill);
490
+ this.skillsImporting = true;
491
+ try {
492
+ const res = await api('import-skills', {
493
+ targetApp: this.skillsTargetApp,
494
+ imports: [skill]
495
+ });
496
+ if (res && res.error) {
497
+ this.showMessage(res.error, 'error');
498
+ return;
499
+ }
500
+ const imported = Array.isArray(res && res.imported) ? res.imported : [];
501
+ if (imported.length > 0) {
502
+ this.showMessage(`已导入 ${skill.displayName || skill.name}`, 'success');
503
+ await this.refreshSkillsList({ silent: true });
504
+ await this.scanImportableSkills({ silent: true });
505
+ }
506
+ } catch (e) {
507
+ this.showMessage('导入失败', 'error');
508
+ } finally {
509
+ this.skillsImporting = false;
510
+ }
480
511
  }
481
512
  };
482
513
  }
@@ -1,6 +1,6 @@
1
1
  <div id="app" class="container" v-cloak>
2
2
  <div v-if="!sessionStandalone" class="top-tabs" role="tablist" :aria-label="t('nav.topTabs.aria')">
3
- <button class="top-tab"
3
+ <button type="button" class="top-tab"
4
4
  id="tab-dashboard"
5
5
  role="tab"
6
6
  data-main-tab="dashboard"
@@ -10,7 +10,7 @@
10
10
  :class="{ active: isMainTabNavActive('dashboard') }"
11
11
  @pointerdown="onMainTabPointerDown('dashboard', $event)"
12
12
  @click="onMainTabClick('dashboard', $event)">{{ t('tab.dashboard') }}</button>
13
- <button class="top-tab"
13
+ <button type="button" class="top-tab"
14
14
  id="tab-docs"
15
15
  role="tab"
16
16
  data-main-tab="docs"
@@ -20,7 +20,7 @@
20
20
  :class="{ active: isMainTabNavActive('docs') }"
21
21
  @pointerdown="onMainTabPointerDown('docs', $event)"
22
22
  @click="onMainTabClick('docs', $event)">{{ t('tab.docs') }}</button>
23
- <button class="top-tab"
23
+ <button type="button" class="top-tab"
24
24
  id="tab-config"
25
25
  role="tab"
26
26
  data-main-tab="config"
@@ -31,7 +31,7 @@
31
31
  :class="{ active: isMainTabNavActive('config') }"
32
32
  @pointerdown="onMainTabPointerDown('config', $event)"
33
33
  @click="onMainTabClick('config', $event)">{{ t('tab.config') }}</button>
34
- <button class="top-tab"
34
+ <button type="button" class="top-tab"
35
35
  id="tab-sessions"
36
36
  role="tab"
37
37
  data-main-tab="sessions"
@@ -41,7 +41,7 @@
41
41
  :class="{ active: isMainTabNavActive('sessions') }"
42
42
  @pointerdown="onMainTabPointerDown('sessions', $event)"
43
43
  @click="onMainTabClick('sessions', $event)">{{ t('tab.sessions') }}</button>
44
- <button class="top-tab"
44
+ <button type="button" class="top-tab"
45
45
  id="tab-usage"
46
46
  role="tab"
47
47
  data-main-tab="usage"
@@ -51,7 +51,7 @@
51
51
  :class="{ active: isMainTabNavActive('usage') }"
52
52
  @pointerdown="onMainTabPointerDown('usage', $event)"
53
53
  @click="onMainTabClick('usage', $event)">{{ t('tab.usage') }}</button>
54
- <button v-if="taskOrchestrationTabEnabled" class="top-tab"
54
+ <button v-if="taskOrchestrationTabEnabled" type="button" class="top-tab"
55
55
  id="tab-orchestration"
56
56
  role="tab"
57
57
  data-main-tab="orchestration"
@@ -61,7 +61,7 @@
61
61
  :class="{ active: isMainTabNavActive('orchestration') }"
62
62
  @pointerdown="onMainTabPointerDown('orchestration', $event)"
63
63
  @click="onMainTabClick('orchestration', $event)">{{ t('tab.orchestration') }}</button>
64
- <button class="top-tab"
64
+ <button type="button" class="top-tab"
65
65
  id="tab-market"
66
66
  role="tab"
67
67
  data-main-tab="market"
@@ -71,7 +71,7 @@
71
71
  :class="{ active: isMainTabNavActive('market') }"
72
72
  @pointerdown="onMainTabPointerDown('market', $event)"
73
73
  @click="onMainTabClick('market', $event)">{{ t('tab.market') }}</button>
74
- <button class="top-tab"
74
+ <button type="button" class="top-tab"
75
75
  id="tab-plugins"
76
76
  role="tab"
77
77
  data-main-tab="plugins"
@@ -81,7 +81,7 @@
81
81
  :class="{ active: isMainTabNavActive('plugins') }"
82
82
  @pointerdown="onMainTabPointerDown('plugins', $event)"
83
83
  @click="onMainTabClick('plugins', $event)">{{ t('tab.plugins') }}</button>
84
- <button class="top-tab"
84
+ <button type="button" class="top-tab"
85
85
  id="tab-settings"
86
86
  role="tab"
87
87
  data-main-tab="settings"
@@ -118,14 +118,13 @@
118
118
 
119
119
  <div :class="['app-shell', { standalone: sessionStandalone }]">
120
120
  <aside class="side-rail" v-if="!sessionStandalone">
121
- <div class="brand-block">
121
+ <div class="brand-block" tabindex="0" @mouseenter="brandHovered = true" @mouseleave="brandHovered = false" @focus="brandHovered = true" @blur="brandHovered = false">
122
122
  <div class="brand-head">
123
123
  <img class="brand-logo" src="/res/logo-pack.webp" alt="Codex Mate logo">
124
124
  <div class="brand-copy">
125
- <div class="brand-kicker">Codex Mate <span v-if="appVersion" class="brand-version">v{{ appVersion }}</span></div>
125
+ <div class="brand-kicker">Codex Mate<transition name="brand-version-fade"><span v-if="appVersion && brandHovered" class="brand-version"> v{{ appVersion }}</span></transition></div>
126
126
  </div>
127
127
  </div>
128
- <div class="brand-subtitle">{{ t('brand.subtitle.localConfigSessionsWorkspace') }}</div>
129
128
  </div>
130
129
 
131
130
  <div class="side-rail-nav">
@@ -479,6 +478,12 @@
479
478
  <span class="value">{{ installRegistryPreview || t('common.defaultOfficial') }}</span>
480
479
  </div>
481
480
  </div>
481
+ <div class="status-strip status-strip-placeholder" v-else-if="!sessionStandalone" aria-hidden="true">
482
+ <div class="status-chip">
483
+ <span class="label">&nbsp;</span>
484
+ <span class="value">&nbsp;</span>
485
+ </div>
486
+ </div>
482
487
  <div
483
488
  v-if="!sessionStandalone && mainTab === 'config' && isProviderConfigMode && forceCompactLayout && !loading && !initError && displayProvidersList.length > 1"
484
489
  class="provider-fast-switch">
@@ -100,7 +100,7 @@
100
100
  </div>
101
101
 
102
102
  <div class="card-list">
103
- <div :class="['card', { active: currentClaudeConfig === 'claude-local' }]" @click="applyClaudeLocalBridge()" @keydown.enter.self.prevent="applyClaudeLocalBridge()" @keydown.space.self.prevent="applyClaudeLocalBridge()" tabindex="0" role="button" :aria-current="currentClaudeConfig === 'claude-local' ? 'true' : null">
103
+ <div :class="['card', { active: currentClaudeConfig === 'claude-local', disabled: isClaudeLocalBridgeDisabled() }]" @click="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" @keydown.enter.self.prevent="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" @keydown.space.self.prevent="isClaudeLocalBridgeDisabled() ? null : applyClaudeLocalBridge()" :tabindex="isClaudeLocalBridgeDisabled() ? -1 : 0" role="button" :aria-current="currentClaudeConfig === 'claude-local' ? 'true' : null">
104
104
  <div class="card-leading">
105
105
  <div class="card-icon">L</div>
106
106
  <div class="card-content">
@@ -108,8 +108,10 @@
108
108
  <svg class="bridge-pool-summary-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M6 8v4h6v4"/><path d="M18 8v4h-6v4"/></svg>
109
109
  <span class="bridge-pool-summary-text">{{ t('claude.localBridge.enabled') }} {{ claudeLocalBridgeCandidateProviders().filter(cp => !isClaudeLocalBridgeExcluded(cp.name)).length }} / {{ claudeLocalBridgeCandidateProviders().length }}</span>
110
110
  </div>
111
- <div class="card-title">claude-local</div>
112
- <div class="card-subtitle card-subtitle-model">{{ t('claude.localBridge.poolHint') }}</div>
111
+ <div class="card-title">
112
+ <span>local</span>
113
+ <span class="provider-readonly-badge">{{ t('config.badge.system') }}</span>
114
+ </div>
113
115
  </div>
114
116
  </div>
115
117
  <div class="card-trailing">
@@ -120,7 +120,7 @@
120
120
  </template>
121
121
 
122
122
  <div v-if="!loading && !initError" class="card-list">
123
- <div v-for="provider in displayProvidersList" :key="provider.name" :class="['card', { active: displayCurrentProvider === provider.name }]" @click="switchProvider(provider.name)" @keydown.enter.self.prevent="switchProvider(provider.name)" @keydown.space.self.prevent="switchProvider(provider.name)" tabindex="0" role="button" :aria-current="displayCurrentProvider === provider.name ? 'true' : null">
123
+ <div v-for="provider in displayProvidersList" :key="provider.name" :class="['card', { active: displayCurrentProvider === provider.name, disabled: provider.name === 'local' && isLocalProviderDisabled }]" @click="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" @keydown.enter.self.prevent="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" @keydown.space.self.prevent="(provider.name === 'local' && isLocalProviderDisabled) ? null : switchProvider(provider.name)" :tabindex="provider.name === 'local' && isLocalProviderDisabled ? -1 : 0" role="button" :aria-current="displayCurrentProvider === provider.name ? 'true' : null">
124
124
  <div class="card-leading">
125
125
  <div class="card-icon">{{ provider.name.charAt(0).toUpperCase() }}<span v-if="isTransformProvider(provider)" class="card-icon-dot" title="通过内建转换适配"></span></div>
126
126
  <div class="card-content">
@@ -4,172 +4,99 @@
4
4
  id="panel-market"
5
5
  role="tabpanel"
6
6
  aria-labelledby="tab-market">
7
- <div class="selector-section market-overview-section">
8
- <div class="selector-header market-overview-header">
9
- <div>
10
- <span class="selector-title">{{ t('market.title') }}</span>
11
- <div class="skills-panel-note">{{ t('market.subtitle') }}</div>
12
- </div>
13
- <div class="settings-tab-actions market-header-actions">
14
- <button type="button" class="btn-tool btn-tool-compact" @click="loadSkillsMarketOverview({ forceRefresh: true, silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
15
- {{ skillsMarketLoading ? t('market.refreshing') : t('market.refresh') }}
16
- </button>
17
- <button type="button" class="btn-tool btn-tool-compact" @click="openSkillsManager" :disabled="loading || !!initError || skillsMarketBusy">
18
- {{ t('market.openManager') }}
7
+ <!-- Minimalist header with target switch -->
8
+ <div class="skills-minimal-header">
9
+ <div class="skills-header-left">
10
+ <span class="skills-header-title">{{ t('market.title') }}</span>
11
+ <div class="skills-target-switch" role="group" :aria-label="t('market.target.aria')">
12
+ <button
13
+ type="button"
14
+ :class="['skills-target-chip', { active: skillsTargetApp === 'codex' }]"
15
+ :aria-pressed="skillsTargetApp === 'codex'"
16
+ :disabled="loading || !!initError || skillsMarketBusy"
17
+ @click="setSkillsTargetApp('codex', { silent: false })">
18
+ Codex
19
19
  </button>
20
- <button type="button" class="btn-tool btn-tool-compact" @click="switchMainTab('dashboard')" :disabled="loading || !!initError || skillsMarketBusy">
21
- {{ t('dashboard.doctor.title') }}
20
+ <button
21
+ type="button"
22
+ :class="['skills-target-chip', { active: skillsTargetApp === 'claude' }]"
23
+ :aria-pressed="skillsTargetApp === 'claude'"
24
+ :disabled="loading || !!initError || skillsMarketBusy"
25
+ @click="setSkillsTargetApp('claude', { silent: false })">
26
+ Claude Code
22
27
  </button>
23
28
  </div>
24
29
  </div>
25
-
26
- <div class="market-target-switch" role="group" :aria-label="t('market.target.aria')">
27
- <button
28
- type="button"
29
- :class="['market-target-chip', { active: skillsTargetApp === 'codex' }]"
30
- :aria-pressed="skillsTargetApp === 'codex'"
31
- :disabled="loading || !!initError || skillsMarketBusy"
32
- @click="setSkillsTargetApp('codex', { silent: false })">
33
- Codex
30
+ <div class="skills-header-actions">
31
+ <button type="button" class="btn-icon" @click="openSkillsMenu" :aria-label="t('common.menu')" :title="t('common.menu')">
32
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
34
33
  </button>
35
- <button
36
- type="button"
37
- :class="['market-target-chip', { active: skillsTargetApp === 'claude' }]"
38
- :aria-pressed="skillsTargetApp === 'claude'"
39
- :disabled="loading || !!initError || skillsMarketBusy"
40
- @click="setSkillsTargetApp('claude', { silent: false })">
41
- Claude Code
42
- </button>
43
- </div>
44
-
45
- <div class="skills-root-box market-root-box">{{ skillsRootPath || skillsDefaultRootPath }}</div>
46
-
47
- <div class="skills-summary-strip market-summary-strip">
48
- <div class="skills-summary-item">
49
- <span class="skills-summary-label">{{ t('market.summary.target') }}</span>
50
- <strong class="skills-summary-value">{{ skillsTargetLabel }}</strong>
51
- </div>
52
- <div class="skills-summary-item">
53
- <span class="skills-summary-label">{{ t('market.summary.total') }}</span>
54
- <strong class="skills-summary-value">{{ skillsList.length }}</strong>
55
- </div>
56
- <div class="skills-summary-item">
57
- <span class="skills-summary-label">{{ t('market.summary.configured') }}</span>
58
- <strong class="skills-summary-value">{{ skillsConfiguredCount }}</strong>
59
- </div>
60
- <div class="skills-summary-item">
61
- <span class="skills-summary-label">{{ t('market.summary.missing') }}</span>
62
- <strong class="skills-summary-value">{{ skillsMissingSkillFileCount }}</strong>
63
- </div>
64
- <div class="skills-summary-item">
65
- <span class="skills-summary-label">{{ t('market.summary.importable') }}</span>
66
- <strong class="skills-summary-value">{{ skillsImportList.length }}</strong>
67
- </div>
68
- <div class="skills-summary-item">
69
- <span class="skills-summary-label">{{ t('market.summary.importableDirect') }}</span>
70
- <strong class="skills-summary-value">{{ skillsImportConfiguredCount }}</strong>
71
- </div>
72
34
  </div>
73
35
  </div>
74
36
 
75
- <div class="market-grid">
76
- <div class="skills-panel market-panel">
77
- <div class="skills-panel-header">
78
- <div class="skills-panel-title-wrap">
79
- <div class="skills-panel-title">{{ t('market.installed.title') }}</div>
80
- <div class="skills-panel-note">{{ t('market.installed.note') }}</div>
81
- </div>
82
- <button type="button" class="btn-mini" @click="refreshSkillsList({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
83
- {{ skillsLoading ? t('market.local.refreshing') : t('market.local.refresh') }}
84
- </button>
85
- </div>
86
- <div v-if="skillsLoading && !skillsMarketLocalLoadedOnce" class="skills-empty-state">{{ t('market.local.loading') }}</div>
87
- <div v-else-if="skillsList.length === 0" class="skills-empty-state">{{ t('market.local.empty') }}</div>
88
- <div v-else class="market-preview-list">
89
- <div v-for="skill in skillsMarketInstalledPreview" :key="'market-local-' + skill.name" class="market-preview-item">
90
- <div class="market-preview-main">
91
- <div class="market-preview-title">{{ skill.displayName || skill.name }}</div>
92
- <div class="market-preview-meta">{{ skill.description || skill.path }}</div>
93
- </div>
94
- <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
95
- {{ skill.hasSkillFile ? t('market.pill.verified') : t('market.pill.missingSkill') }}
96
- </span>
97
- </div>
98
- </div>
37
+ <!-- Installed skills panel -->
38
+ <div class="skills-flow-panel">
39
+ <div class="skills-flow-header">
40
+ <div class="skills-flow-title-wrap">
41
+ <span class="skills-flow-title">{{ t('market.installed.title') }}</span>
42
+ <span class="skills-flow-count">{{ skillsList.length }}</span>
43
+ </div>
44
+ <button type="button" class="btn-mini" @click="refreshSkillsList({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
45
+ <svg v-if="!skillsLoading" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="14" height="14"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 014-4h14"/><path d="M3 19l-4-4 4-4"/><path d="M17 9v2a4 4 0 01-4 4H3"/></svg>
46
+ <svg v-else viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="14" height="14" class="spin"><path d="M10 2v4m0 8v4M2 10h4m8 0h4m-3.5-6.5l2.5 2.5M4 4l2.5 2.5M16 16l-2.5-2.5M4 16l2.5-2.5"/></svg>
47
+ </button>
99
48
  </div>
100
-
101
- <div class="skills-panel market-panel">
102
- <div class="skills-panel-header">
103
- <div class="skills-panel-title-wrap">
104
- <div class="skills-panel-title">{{ t('market.import.title') }}</div>
105
- <div class="skills-panel-note">{{ t('market.import.note', { target: skillsTargetLabel }) }}</div>
106
- </div>
107
- <button type="button" class="btn-mini" @click="scanImportableSkills({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
108
- {{ skillsScanningImports ? t('market.import.scanning') : t('market.import.scan') }}
109
- </button>
110
- </div>
111
- <div v-if="skillsScanningImports && !skillsMarketImportLoadedOnce" class="skills-empty-state">{{ t('market.import.loading') }}</div>
112
- <div v-else-if="skillsImportList.length === 0" class="skills-empty-state">{{ t('market.import.empty') }}</div>
113
- <div v-else class="market-preview-list">
114
- <div v-for="skill in skillsMarketImportPreview" :key="'market-import-' + buildSkillImportKey(skill)" class="market-preview-item">
115
- <div class="market-preview-main">
116
- <div class="market-preview-title">{{ skill.displayName || skill.name }}</div>
117
- <div class="market-preview-meta">{{ skill.sourceLabel }} · {{ skill.sourcePath }}</div>
118
- </div>
119
- <span :class="['pill', skill.hasSkillFile ? 'configured' : 'empty']">
120
- {{ skill.hasSkillFile ? t('market.pill.importableDirect') : t('market.pill.importMissing') }}
121
- </span>
49
+ <div v-if="skillsLoading && !skillsMarketLocalLoadedOnce" class="skills-flow-loading">{{ t('market.local.loading') }}</div>
50
+ <div v-else-if="skillsList.length === 0" class="skills-flow-empty">{{ t('market.local.empty') }}</div>
51
+ <div v-else class="skills-flow-list">
52
+ <div v-for="skill in skillsList" :key="'skill-' + skill.name" class="skills-flow-item" :class="{ 'has-issue': !skill.hasSkillFile }">
53
+ <div class="skills-flow-main">
54
+ <span class="skills-flow-name">{{ skill.displayName || skill.name }}</span>
55
+ <span class="skills-flow-path">{{ skill.path }}</span>
122
56
  </div>
57
+ <span :class="['skills-flow-status', skill.hasSkillFile ? 'success' : 'warning']">
58
+ {{ skill.hasSkillFile ? t('market.pill.verified') : t('market.pill.missingSkill') }}
59
+ </span>
123
60
  </div>
124
61
  </div>
62
+ </div>
125
63
 
126
- <div class="skills-panel market-panel market-actions-panel">
127
- <div class="skills-panel-header">
128
- <div class="skills-panel-title-wrap">
129
- <div class="skills-panel-title">{{ t('market.actions.title') }}</div>
130
- <div class="skills-panel-note">{{ t('market.actions.note') }}</div>
131
- </div>
132
- </div>
133
- <div class="market-action-grid">
134
- <button type="button" class="market-action-card" @click="openSkillsManager" :disabled="loading || !!initError || skillsMarketBusy">
135
- <span class="market-action-title"><svg style="width:14px;height:14px;vertical-align:-2px;margin-right:4px" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="14" height="14" rx="3"/><path d="M7 7h6M7 10h4"/></svg>{{ t('market.action.manage.title') }}</span>
136
- <span class="market-action-copy">{{ t('market.action.manage.copy', { target: skillsTargetLabel }) }}</span>
137
- </button>
138
- <button type="button" class="market-action-card" @click="scanImportableSkills({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
139
- <span class="market-action-title"><svg style="width:14px;height:14px;vertical-align:-2px;margin-right:4px" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l3-3 3 3"/><path d="M7 3v6"/><path d="M13 7l3 3 3-3"/><path d="M16 16V7"/></svg>{{ t('market.action.crossImport.title') }}</span>
140
- <span class="market-action-copy">{{ t('market.action.crossImport.copy', { target: skillsTargetLabel }) }}</span>
64
+ <!-- Importable skills panel -->
65
+ <div class="skills-flow-panel">
66
+ <div class="skills-flow-header">
67
+ <div class="skills-flow-title-wrap">
68
+ <span class="skills-flow-title">{{ t('market.import.title') }}</span>
69
+ <span class="skills-flow-count">{{ skillsImportList.length }}</span>
70
+ </div>
71
+ <button type="button" class="btn-mini" @click="scanImportableSkills({ silent: false })" :disabled="loading || !!initError || skillsMarketBusy">
72
+ <svg v-if="!skillsScanningImports" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="14" height="14"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 014-4h14"/><path d="M3 19l-4-4 4-4"/><path d="M17 9v2a4 4 0 01-4 4H3"/></svg>
73
+ <svg v-else viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="14" height="14" class="spin"><path d="M10 2v4m0 8v4M2 10h4m8 0h4m-3.5-6.5l2.5 2.5M4 4l2.5 2.5M16 16l-2.5-2.5M4 16l2.5-2.5"/></svg>
74
+ </button>
75
+ </div>
76
+ <div v-if="skillsScanningImports && !skillsMarketImportLoadedOnce" class="skills-flow-loading">{{ t('market.import.loading') }}</div>
77
+ <div v-else-if="skillsImportList.length === 0" class="skills-flow-empty">{{ t('market.import.empty') }}</div>
78
+ <div v-else>
79
+ <!-- Quick actions -->
80
+ <div class="skills-flow-actions">
81
+ <button type="button" class="skills-action-btn" @click="openSkillsManager" :disabled="loading || !!initError || skillsMarketBusy">
82
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16"><rect x="3" y="3" width="14" height="14" rx="3"/><path d="M7 7h6M7 10h4"/></svg>
83
+ {{ t('market.action.manage.title') }}
141
84
  </button>
142
- <button type="button" class="market-action-card" @click="triggerSkillsZipImport" :disabled="loading || !!initError || skillsMarketBusy">
143
- <span class="market-action-title"><svg style="width:14px;height:14px;vertical-align:-2px;margin-right:4px" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 14l3-3 3 3"/><path d="M7 4v7"/><rect x="12" y="4" width="5" height="12" rx="1"/></svg>{{ t('market.action.zipImport.title') }}</span>
144
- <span class="market-action-copy">{{ t('market.action.zipImport.copy') }}</span>
85
+ <button type="button" class="skills-action-btn" @click="triggerSkillsZipImport" :disabled="loading || !!initError || skillsMarketBusy">
86
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16"><path d="M4 14l3-3 3 3"/><path d="M7 4v7"/><rect x="12" y="4" width="5" height="12" rx="1"/></svg>
87
+ {{ t('market.action.zipImport.title') }}
145
88
  </button>
146
89
  </div>
147
- </div>
148
-
149
- <div class="skills-panel market-panel market-panel-wide">
150
- <div class="skills-panel-header">
151
- <div class="skills-panel-title-wrap">
152
- <div class="skills-panel-title">{{ t('market.help.title') }}</div>
153
- </div>
154
- </div>
155
- <div class="market-preview-list">
156
- <div class="market-preview-item">
157
- <div class="market-preview-main">
158
- <div class="market-preview-title">{{ t('market.help.target.title') }}</div>
159
- <div class="market-preview-meta">{{ t('market.help.target.copy', { target: skillsTargetLabel }) }}</div>
160
- </div>
161
- </div>
162
- <div class="market-preview-item">
163
- <div class="market-preview-main">
164
- <div class="market-preview-title">{{ t('market.help.crossImport.title') }}</div>
165
- <div class="market-preview-meta">{{ t('market.help.crossImport.copy') }}</div>
166
- </div>
167
- </div>
168
- <div class="market-preview-item">
169
- <div class="market-preview-main">
170
- <div class="market-preview-title">{{ t('market.help.zipImport.title') }}</div>
171
- <div class="market-preview-meta">{{ t('market.help.zipImport.copy') }}</div>
90
+ <!-- Import list -->
91
+ <div class="skills-flow-list">
92
+ <div v-for="skill in skillsImportList" :key="'import-' + buildSkillImportKey(skill)" class="skills-flow-item">
93
+ <div class="skills-flow-main">
94
+ <span class="skills-flow-name">{{ skill.displayName || skill.name }}</span>
95
+ <span class="skills-flow-meta">{{ skill.sourceLabel }}</span>
172
96
  </div>
97
+ <button type="button" class="skills-flow-add" @click="importSingleSkill(skill)" :disabled="loading || !!initError || skillsMarketBusy">
98
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" width="16" height="16"><path d="M10 4v6m3-3l-3 3-3-3"/></svg>
99
+ </button>
173
100
  </div>
174
101
  </div>
175
102
  </div>