mulmoclaude 0.6.2 → 0.6.4
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/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- package/src/composables/useSelectedResult.ts +0 -49
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
<div>
|
|
6
6
|
<h2 class="text-lg font-semibold text-gray-800">{{ t("pluginManageSkills.heading") }}</h2>
|
|
7
7
|
<p class="text-xs text-gray-400 mt-0.5">{{ t("pluginManageSkills.subheading", { count: skills.length }) }}</p>
|
|
8
|
+
<i18n-t keypath="pluginManageSkills.sectionLegend" tag="p" class="text-xs text-gray-400 mt-0.5">
|
|
9
|
+
<template #system>
|
|
10
|
+
<span class="material-icons !text-sm align-middle leading-none text-gray-500" aria-hidden="true">lock</span>
|
|
11
|
+
</template>
|
|
12
|
+
<template #project>
|
|
13
|
+
<span class="material-icons !text-sm align-middle leading-none text-green-600" aria-hidden="true">folder</span>
|
|
14
|
+
</template>
|
|
15
|
+
<template #user>
|
|
16
|
+
<span class="material-icons !text-sm align-middle leading-none text-blue-500" aria-hidden="true">home</span>
|
|
17
|
+
</template>
|
|
18
|
+
</i18n-t>
|
|
8
19
|
</div>
|
|
9
20
|
</div>
|
|
10
21
|
|
|
@@ -14,34 +25,280 @@
|
|
|
14
25
|
</div>
|
|
15
26
|
|
|
16
27
|
<div class="flex-1 min-h-0 flex overflow-hidden">
|
|
17
|
-
<!-- Left:
|
|
28
|
+
<!-- Left: two collapsible sections — Active (discovered by
|
|
29
|
+
Claude Code, loaded into the prompt) and Catalog (browse /
|
|
30
|
+
★ star / ▶ run once without bloating the prompt). Aligns
|
|
31
|
+
with the #1335 catalog/active model. -->
|
|
18
32
|
<div class="w-64 shrink-0 border-r border-gray-100 overflow-y-auto bg-gray-50">
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
<!-- ★ Active -->
|
|
34
|
+
<div data-testid="skill-section-active">
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
data-testid="skill-section-toggle-active"
|
|
38
|
+
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide hover:bg-gray-100 border-b border-gray-100"
|
|
39
|
+
:aria-expanded="isSectionOpen('active')"
|
|
40
|
+
aria-controls="skill-section-panel-active"
|
|
41
|
+
@click="toggleSection('active')"
|
|
42
|
+
>
|
|
43
|
+
<span class="flex items-center gap-1">
|
|
44
|
+
<span class="material-icons text-base">{{ isSectionOpen("active") ? "expand_more" : "chevron_right" }}</span>
|
|
45
|
+
{{ t("pluginManageSkills.sectionActive") }}
|
|
46
|
+
</span>
|
|
47
|
+
<span data-testid="skill-section-count-active" class="text-gray-400 font-normal normal-case">{{ activeSkills.length }}</span>
|
|
48
|
+
</button>
|
|
49
|
+
<div v-show="isSectionOpen('active')" id="skill-section-panel-active" role="group">
|
|
50
|
+
<div
|
|
51
|
+
v-for="skill in activeSkills"
|
|
52
|
+
:key="skill.name"
|
|
53
|
+
:data-testid="`skill-item-${skill.name}`"
|
|
54
|
+
class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
|
|
55
|
+
:class="selectedName === skill.name && !selectedCatalog ? 'bg-white border-l-2 border-l-blue-500' : ''"
|
|
56
|
+
role="button"
|
|
57
|
+
tabindex="0"
|
|
58
|
+
:aria-pressed="selectedName === skill.name && !selectedCatalog"
|
|
59
|
+
@click="selectActiveSkill(skill.name)"
|
|
60
|
+
@keydown.enter.prevent="selectActiveSkill(skill.name)"
|
|
61
|
+
@keydown.space.prevent="selectActiveSkill(skill.name)"
|
|
62
|
+
>
|
|
63
|
+
<div class="flex items-center gap-2">
|
|
64
|
+
<div class="flex-1 min-w-0">
|
|
65
|
+
<div class="font-medium text-gray-800 truncate">{{ skill.name }}</div>
|
|
66
|
+
<div class="text-xs text-gray-500 truncate mt-0.5">
|
|
67
|
+
{{ skill.description }}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<span class="shrink-0 material-icons text-sm" :class="skillBadge(skill).colour" :title="skillBadge(skill).title" aria-hidden="true">{{
|
|
71
|
+
skillBadge(skill).icon
|
|
72
|
+
}}</span>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<i18n-t v-if="activeSkills.length === 0" keypath="pluginManageSkills.emptyWithPath" tag="p" class="p-4 text-sm text-gray-400 italic">
|
|
76
|
+
<template #path>
|
|
77
|
+
<code class="text-[11px]">{{ t("pluginManageSkills.emptySkillPath") }}</code>
|
|
78
|
+
</template>
|
|
79
|
+
</i18n-t>
|
|
30
80
|
</div>
|
|
31
|
-
|
|
32
|
-
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- 📚 Catalog: launcher-managed presets. Rows behave like the
|
|
84
|
+
active list — click selects an entry, loading its detail
|
|
85
|
+
into the right pane with ★ Star / ▶ Run once actions.
|
|
86
|
+
Anthropic + Community sub-catalogs land with #1335 PR-C. -->
|
|
87
|
+
<div data-testid="skill-section-catalog" class="border-t border-gray-200">
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
data-testid="skill-section-toggle-catalog"
|
|
91
|
+
class="w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide hover:bg-gray-100 border-b border-gray-100"
|
|
92
|
+
:aria-expanded="isSectionOpen('catalog')"
|
|
93
|
+
aria-controls="skill-section-panel-catalog"
|
|
94
|
+
@click="toggleSection('catalog')"
|
|
95
|
+
>
|
|
96
|
+
<span class="flex items-center gap-1">
|
|
97
|
+
<span class="material-icons text-base">{{ isSectionOpen("catalog") ? "expand_more" : "chevron_right" }}</span>
|
|
98
|
+
{{ t("pluginManageSkills.sectionCatalog") }}
|
|
99
|
+
</span>
|
|
100
|
+
<span data-testid="skill-section-count-catalog" class="text-gray-400 font-normal normal-case">{{
|
|
101
|
+
catalogPresets.length + catalogExternal.length
|
|
102
|
+
}}</span>
|
|
103
|
+
</button>
|
|
104
|
+
<div v-show="isSectionOpen('catalog')" id="skill-section-panel-catalog" role="group">
|
|
105
|
+
<div class="px-4 py-2 text-[11px] uppercase tracking-wide text-gray-500 font-semibold" data-testid="skill-catalog-section-heading">
|
|
106
|
+
{{ t("pluginManageSkills.catalogPresetHeading") }}
|
|
107
|
+
</div>
|
|
108
|
+
<div
|
|
109
|
+
v-for="entry in catalogPresets"
|
|
110
|
+
:key="`catalog-preset-${entryKey(entry)}`"
|
|
111
|
+
:data-testid="`skill-catalog-item-${entryKey(entry)}`"
|
|
112
|
+
class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
|
|
113
|
+
:class="selectedCatalogKey === entryKey(entry) ? 'bg-white border-l-2 border-l-blue-500' : ''"
|
|
114
|
+
role="button"
|
|
115
|
+
tabindex="0"
|
|
116
|
+
:aria-pressed="selectedCatalogKey === entryKey(entry)"
|
|
117
|
+
@click="selectCatalogEntry(entry)"
|
|
118
|
+
@keydown.enter.prevent="selectCatalogEntry(entry)"
|
|
119
|
+
@keydown.space.prevent="selectCatalogEntry(entry)"
|
|
120
|
+
>
|
|
121
|
+
<div class="flex items-center gap-2">
|
|
122
|
+
<div class="flex-1 min-w-0">
|
|
123
|
+
<div class="font-medium text-gray-700 truncate">{{ entry.name }}</div>
|
|
124
|
+
<div class="text-xs text-gray-500 truncate mt-0.5">{{ entry.description }}</div>
|
|
125
|
+
</div>
|
|
126
|
+
<span
|
|
127
|
+
v-if="entry.alreadyActive"
|
|
128
|
+
class="shrink-0 material-icons text-sm text-yellow-500"
|
|
129
|
+
:title="t('pluginManageSkills.catalogStarred')"
|
|
130
|
+
:data-testid="`skill-catalog-starred-indicator-${entryKey(entry)}`"
|
|
131
|
+
aria-hidden="true"
|
|
132
|
+
>star</span
|
|
133
|
+
>
|
|
134
|
+
<span class="shrink-0 material-icons text-sm" :class="presetSourceMeta.colour" :title="presetSourceMeta.title" aria-hidden="true">{{
|
|
135
|
+
presetSourceMeta.icon
|
|
136
|
+
}}</span>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
<p v-if="catalogPresets.length === 0 && !catalogError" class="px-4 py-3 text-xs text-gray-400 italic" data-testid="skill-catalog-empty">
|
|
140
|
+
{{ t("pluginManageSkills.catalogEmpty") }}
|
|
141
|
+
</p>
|
|
142
|
+
<div v-if="catalogError" class="px-4 py-2 text-xs text-red-600">{{ catalogError }}</div>
|
|
143
|
+
|
|
144
|
+
<!-- External repos (#1383 PR-C2): one collapsible subgroup
|
|
145
|
+
per installed repo. Rows behave exactly like preset
|
|
146
|
+
rows (select → right pane with ★ Star / ▶ Run once). -->
|
|
147
|
+
<div
|
|
148
|
+
v-for="group in externalGroups"
|
|
149
|
+
:key="`catalog-repo-${group.repo.repoId}`"
|
|
150
|
+
:data-testid="`skill-catalog-repo-${group.repo.repoId}`"
|
|
151
|
+
class="border-t border-gray-100"
|
|
152
|
+
>
|
|
153
|
+
<div class="w-full flex items-center hover:bg-gray-100">
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
:data-testid="`skill-catalog-repo-toggle-${group.repo.repoId}`"
|
|
157
|
+
class="flex-1 min-w-0 flex items-center gap-1 px-4 py-2 text-[11px] uppercase tracking-wide text-gray-500 font-semibold"
|
|
158
|
+
:aria-expanded="isRepoOpen(group.repo.repoId)"
|
|
159
|
+
@click="toggleRepo(group.repo.repoId)"
|
|
160
|
+
>
|
|
161
|
+
<span class="material-icons text-sm">{{ isRepoOpen(group.repo.repoId) ? "expand_more" : "chevron_right" }}</span>
|
|
162
|
+
<span class="truncate normal-case text-gray-600">{{ repoLabel(group.repo) }}</span>
|
|
163
|
+
<span class="text-gray-400 font-normal">({{ group.entries.length }})</span>
|
|
164
|
+
</button>
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
class="h-8 w-8 flex items-center justify-center rounded text-gray-400 hover:text-blue-600 disabled:opacity-40"
|
|
168
|
+
:data-testid="`skill-catalog-repo-update-${group.repo.repoId}`"
|
|
169
|
+
:disabled="updatingRepoId === group.repo.repoId"
|
|
170
|
+
:title="t('pluginManageSkills.catalogUpdateRepo')"
|
|
171
|
+
:aria-label="t('pluginManageSkills.catalogUpdateRepo')"
|
|
172
|
+
:aria-busy="updatingRepoId === group.repo.repoId"
|
|
173
|
+
@click="updateRepo(group.repo)"
|
|
174
|
+
>
|
|
175
|
+
<span class="material-icons text-sm" :class="updatingRepoId === group.repo.repoId ? 'animate-spin' : ''" aria-hidden="true">refresh</span>
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
class="h-8 w-8 flex items-center justify-center rounded text-gray-400 hover:text-red-600 disabled:opacity-40"
|
|
180
|
+
:data-testid="`skill-catalog-repo-uninstall-${group.repo.repoId}`"
|
|
181
|
+
:disabled="uninstallingRepoId === group.repo.repoId"
|
|
182
|
+
:title="t('pluginManageSkills.catalogUninstallRepo')"
|
|
183
|
+
:aria-label="t('pluginManageSkills.catalogUninstallRepo')"
|
|
184
|
+
:aria-busy="uninstallingRepoId === group.repo.repoId"
|
|
185
|
+
@click="uninstallRepo(group.repo.repoId)"
|
|
186
|
+
>
|
|
187
|
+
<span class="material-icons text-sm" aria-hidden="true">delete_outline</span>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div v-show="isRepoOpen(group.repo.repoId)" role="group">
|
|
191
|
+
<div
|
|
192
|
+
v-for="entry in group.entries"
|
|
193
|
+
:key="`catalog-ext-${entryKey(entry)}`"
|
|
194
|
+
:data-testid="`skill-catalog-item-${entryKey(entry)}`"
|
|
195
|
+
class="cursor-pointer px-4 py-3 border-b border-gray-100 text-sm hover:bg-white transition-colors focus:outline-none focus:bg-white focus:border-l-2 focus:border-l-blue-400"
|
|
196
|
+
:class="selectedCatalogKey === entryKey(entry) ? 'bg-white border-l-2 border-l-blue-500' : ''"
|
|
197
|
+
role="button"
|
|
198
|
+
tabindex="0"
|
|
199
|
+
:aria-pressed="selectedCatalogKey === entryKey(entry)"
|
|
200
|
+
@click="selectCatalogEntry(entry)"
|
|
201
|
+
@keydown.enter.prevent="selectCatalogEntry(entry)"
|
|
202
|
+
@keydown.space.prevent="selectCatalogEntry(entry)"
|
|
203
|
+
>
|
|
204
|
+
<div class="flex items-center gap-2">
|
|
205
|
+
<div class="flex-1 min-w-0">
|
|
206
|
+
<div class="font-medium text-gray-700 truncate">{{ entry.name }}</div>
|
|
207
|
+
<div class="text-xs text-gray-500 truncate mt-0.5">{{ entry.description }}</div>
|
|
208
|
+
</div>
|
|
209
|
+
<span
|
|
210
|
+
v-if="entry.alreadyActive"
|
|
211
|
+
class="shrink-0 material-icons text-sm text-yellow-500"
|
|
212
|
+
:title="t('pluginManageSkills.catalogStarred')"
|
|
213
|
+
:data-testid="`skill-catalog-starred-indicator-${entryKey(entry)}`"
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
>star</span
|
|
216
|
+
>
|
|
217
|
+
<span class="shrink-0 material-icons text-sm text-gray-400" :title="t('pluginManageSkills.sourceExternalTitle')" aria-hidden="true"
|
|
218
|
+
>cloud</span
|
|
219
|
+
>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<p v-if="group.entries.length === 0" class="px-4 py-3 text-xs text-gray-400 italic">
|
|
223
|
+
{{ t("pluginManageSkills.catalogRepoEmpty") }}
|
|
224
|
+
</p>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
data-testid="skill-catalog-add-repo"
|
|
231
|
+
class="w-full flex items-center gap-1 px-4 py-3 text-sm text-blue-600 hover:bg-white border-t border-gray-100"
|
|
232
|
+
@click="openAddRepo"
|
|
233
|
+
>
|
|
234
|
+
<span class="material-icons text-sm" aria-hidden="true">add</span>
|
|
235
|
+
{{ t("pluginManageSkills.catalogAddRepo") }}
|
|
236
|
+
</button>
|
|
33
237
|
</div>
|
|
34
238
|
</div>
|
|
35
|
-
<i18n-t v-if="skills.length === 0" keypath="pluginManageSkills.emptyWithPath" tag="p" class="p-4 text-sm text-gray-400 italic">
|
|
36
|
-
<template #path>
|
|
37
|
-
<code class="text-[11px]">{{ t("pluginManageSkills.emptySkillPath") }}</code>
|
|
38
|
-
</template>
|
|
39
|
-
</i18n-t>
|
|
40
239
|
</div>
|
|
41
240
|
|
|
42
241
|
<!-- Right: detail pane -->
|
|
43
242
|
<div class="flex-1 min-w-0 overflow-y-auto">
|
|
44
|
-
|
|
243
|
+
<!-- Catalog (preset) detail. Selecting a row from the
|
|
244
|
+
"Preset catalog" section in the left column routes
|
|
245
|
+
here. Shows description + body + Star / Run once
|
|
246
|
+
actions. (#1335 PR-B2 follow-up — replaces the inline
|
|
247
|
+
buttons and the Preview modal with a single right-pane
|
|
248
|
+
that mirrors the active-skill view.) -->
|
|
249
|
+
<div v-if="selectedCatalog" class="p-6" data-testid="skill-catalog-detail-pane">
|
|
250
|
+
<div class="flex items-start justify-between gap-4 mb-4">
|
|
251
|
+
<div class="min-w-0">
|
|
252
|
+
<div class="flex items-center gap-2 mb-1">
|
|
253
|
+
<span class="material-icons text-sm" :class="presetSourceMeta.colour" :title="presetSourceMeta.title" aria-hidden="true">{{
|
|
254
|
+
presetSourceMeta.icon
|
|
255
|
+
}}</span>
|
|
256
|
+
<h3 class="text-xl font-semibold text-gray-800 truncate">{{ selectedCatalog.name }}</h3>
|
|
257
|
+
</div>
|
|
258
|
+
<p class="text-sm text-gray-600 mt-1">{{ selectedCatalog.description }}</p>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
261
|
+
<button
|
|
262
|
+
v-if="!selectedCatalog.alreadyActive"
|
|
263
|
+
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-yellow-400 text-yellow-600 hover:bg-yellow-50 disabled:opacity-40"
|
|
264
|
+
:disabled="catalogActioningKey === selectedCatalogKey"
|
|
265
|
+
:title="t('pluginManageSkills.catalogStar')"
|
|
266
|
+
data-testid="skill-catalog-detail-star-btn"
|
|
267
|
+
@click="starCatalogEntry(selectedCatalog)"
|
|
268
|
+
>
|
|
269
|
+
<span class="material-icons text-sm" aria-hidden="true">star_border</span>
|
|
270
|
+
{{ t("pluginManageSkills.catalogStar") }}
|
|
271
|
+
</button>
|
|
272
|
+
<button
|
|
273
|
+
v-else
|
|
274
|
+
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded text-yellow-500 cursor-not-allowed"
|
|
275
|
+
:title="t('pluginManageSkills.catalogStarred')"
|
|
276
|
+
data-testid="skill-catalog-detail-starred"
|
|
277
|
+
disabled
|
|
278
|
+
>
|
|
279
|
+
<span class="material-icons text-sm" aria-hidden="true">star</span>
|
|
280
|
+
{{ t("pluginManageSkills.catalogStarred") }}
|
|
281
|
+
</button>
|
|
282
|
+
<button
|
|
283
|
+
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40"
|
|
284
|
+
:disabled="catalogActioningKey === selectedCatalogKey || !catalogDetail"
|
|
285
|
+
:title="t('pluginManageSkills.catalogRunOnce')"
|
|
286
|
+
data-testid="skill-catalog-detail-run-btn"
|
|
287
|
+
@click="runOnceCatalogEntry(selectedCatalog)"
|
|
288
|
+
>
|
|
289
|
+
<span class="material-icons text-sm" aria-hidden="true">play_arrow</span>
|
|
290
|
+
{{ t("pluginManageSkills.catalogRunOnce") }}
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
<div v-if="catalogDetailLoading" class="text-sm text-gray-400 italic">{{ t("pluginManageSkills.loading") }}</div>
|
|
295
|
+
<div v-else-if="catalogError" class="text-sm text-red-600">{{ catalogError }}</div>
|
|
296
|
+
<!-- eslint-disable vue/no-v-html -- markdown sanitized via sanitizeMarkdownHtml; same trust chain as the active-skill body below -->
|
|
297
|
+
<div v-else-if="catalogDetail" class="markdown-content text-gray-700" v-html="catalogRenderedBody"></div>
|
|
298
|
+
<!-- eslint-enable vue/no-v-html -->
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div v-else-if="!selected" class="p-6 text-sm text-gray-400 italic">{{ t("pluginManageSkills.selectHint") }}</div>
|
|
45
302
|
<div v-else class="p-6">
|
|
46
303
|
<div class="flex items-start justify-between gap-4 mb-4">
|
|
47
304
|
<div class="min-w-0">
|
|
@@ -73,7 +330,7 @@
|
|
|
73
330
|
</template>
|
|
74
331
|
<template v-else>
|
|
75
332
|
<button
|
|
76
|
-
v-if="
|
|
333
|
+
v-if="isSelectedEditable"
|
|
77
334
|
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
|
78
335
|
:disabled="detailLoading"
|
|
79
336
|
data-testid="skill-edit-btn"
|
|
@@ -83,7 +340,7 @@
|
|
|
83
340
|
{{ t("pluginManageSkills.btnEdit") }}
|
|
84
341
|
</button>
|
|
85
342
|
<button
|
|
86
|
-
v-if="
|
|
343
|
+
v-if="isSelectedEditable"
|
|
87
344
|
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-40"
|
|
88
345
|
:disabled="detailLoading || deleting"
|
|
89
346
|
data-testid="skill-delete-btn"
|
|
@@ -142,22 +399,119 @@
|
|
|
142
399
|
</div>
|
|
143
400
|
</div>
|
|
144
401
|
</div>
|
|
402
|
+
|
|
403
|
+
<!-- Add-repo modal (#1383 PR-C2). URL (+ optional subpath) or a
|
|
404
|
+
one-click seed suggestion. Backend error kinds (invalid-url /
|
|
405
|
+
invalid-subpath / id-collision / no-skills / 502) surface
|
|
406
|
+
inline. -->
|
|
407
|
+
<div
|
|
408
|
+
v-if="addRepoOpen"
|
|
409
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
|
410
|
+
data-testid="skill-add-repo-modal"
|
|
411
|
+
@click.self="addRepoOpen = false"
|
|
412
|
+
>
|
|
413
|
+
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-5">
|
|
414
|
+
<h3 class="text-base font-semibold text-gray-800 mb-3">{{ t("pluginManageSkills.catalogAddRepoTitle") }}</h3>
|
|
415
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageSkills.catalogRepoUrlLabel") }}</label>
|
|
416
|
+
<input
|
|
417
|
+
v-model="addRepoUrl"
|
|
418
|
+
type="text"
|
|
419
|
+
data-testid="skill-add-repo-url"
|
|
420
|
+
class="w-full h-8 px-2 mb-3 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
|
|
421
|
+
:placeholder="t('pluginManageSkills.catalogRepoUrlPlaceholder')"
|
|
422
|
+
@keydown.enter="installRepo(addRepoUrl, addRepoSubpath)"
|
|
423
|
+
/>
|
|
424
|
+
<label class="block text-xs font-medium text-gray-600 mb-1">{{ t("pluginManageSkills.catalogRepoSubpathLabel") }}</label>
|
|
425
|
+
<input
|
|
426
|
+
v-model="addRepoSubpath"
|
|
427
|
+
type="text"
|
|
428
|
+
data-testid="skill-add-repo-subpath"
|
|
429
|
+
class="w-full h-8 px-2 mb-3 text-sm border border-gray-300 rounded focus:outline-none focus:border-blue-400"
|
|
430
|
+
:placeholder="t('pluginManageSkills.catalogRepoSubpathPlaceholder')"
|
|
431
|
+
@keydown.enter="installRepo(addRepoUrl, addRepoSubpath)"
|
|
432
|
+
/>
|
|
433
|
+
<p v-if="addRepoError" class="text-xs text-red-600 mb-3" data-testid="skill-add-repo-error">{{ addRepoError }}</p>
|
|
434
|
+
<div class="flex items-center justify-end gap-2 mb-4">
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
class="h-8 px-2.5 flex items-center text-sm rounded border border-gray-300 text-gray-600 hover:bg-gray-50"
|
|
438
|
+
@click="addRepoOpen = false"
|
|
439
|
+
>
|
|
440
|
+
{{ t("common.cancel") }}
|
|
441
|
+
</button>
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
data-testid="skill-add-repo-submit"
|
|
445
|
+
class="h-8 px-2.5 flex items-center gap-1 text-sm rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-40"
|
|
446
|
+
:disabled="addRepoBusy"
|
|
447
|
+
@click="installRepo(addRepoUrl, addRepoSubpath)"
|
|
448
|
+
>
|
|
449
|
+
{{ addRepoBusy ? t("pluginManageSkills.catalogRepoInstalling") : t("pluginManageSkills.catalogAddRepoSubmit") }}
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
<div v-if="suggestions.length > 0">
|
|
453
|
+
<p class="text-xs font-medium text-gray-600 mb-2">{{ t("pluginManageSkills.catalogAddRepoSuggestions") }}</p>
|
|
454
|
+
<div
|
|
455
|
+
v-for="suggestion in suggestions"
|
|
456
|
+
:key="suggestion.url"
|
|
457
|
+
class="mb-1 rounded border"
|
|
458
|
+
:class="selectedSuggestionUrl === suggestion.url ? 'border-blue-400 bg-blue-50' : 'border-gray-200'"
|
|
459
|
+
>
|
|
460
|
+
<div class="flex items-start">
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
:data-testid="`skill-add-repo-suggestion-${suggestion.url}`"
|
|
464
|
+
class="flex-1 min-w-0 text-left px-3 py-2 text-sm"
|
|
465
|
+
:aria-pressed="selectedSuggestionUrl === suggestion.url"
|
|
466
|
+
@click="selectSuggestion(suggestion)"
|
|
467
|
+
>
|
|
468
|
+
<div class="font-medium text-gray-700">{{ suggestion.displayName }}</div>
|
|
469
|
+
<div class="text-xs text-gray-500" :class="selectedSuggestionUrl === suggestion.url ? 'whitespace-normal break-words' : 'truncate'">
|
|
470
|
+
{{ suggestion.description }}
|
|
471
|
+
</div>
|
|
472
|
+
</button>
|
|
473
|
+
<a
|
|
474
|
+
:href="suggestion.url"
|
|
475
|
+
target="_blank"
|
|
476
|
+
rel="noopener noreferrer"
|
|
477
|
+
:data-testid="`skill-add-repo-suggestion-link-${suggestion.url}`"
|
|
478
|
+
class="h-8 w-8 shrink-0 flex items-center justify-center rounded text-gray-400 hover:text-blue-600"
|
|
479
|
+
:title="t('pluginManageSkills.catalogRepoOpenLink')"
|
|
480
|
+
:aria-label="t('pluginManageSkills.catalogRepoOpenLink')"
|
|
481
|
+
@click.stop
|
|
482
|
+
>
|
|
483
|
+
<span class="material-icons text-sm" aria-hidden="true">open_in_new</span>
|
|
484
|
+
</a>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
145
490
|
</div>
|
|
146
491
|
</template>
|
|
147
492
|
|
|
148
493
|
<script setup lang="ts">
|
|
149
|
-
import { computed, onMounted, ref, watch } from "vue";
|
|
494
|
+
import { computed, onMounted, ref, shallowRef, watch } from "vue";
|
|
150
495
|
import { useI18n } from "vue-i18n";
|
|
151
496
|
import { marked } from "marked";
|
|
152
497
|
import type { ToolResultComplete } from "gui-chat-protocol/vue";
|
|
153
498
|
import type { ManageSkillsData, SkillSummary } from "./index";
|
|
154
499
|
import { useAppApi } from "../../composables/useAppApi";
|
|
155
|
-
import { apiGet, apiPut, apiDelete } from "../../utils/api";
|
|
500
|
+
import { apiGet, apiPost, apiPut, apiDelete } from "../../utils/api";
|
|
156
501
|
import { handleExternalLinkClick } from "../../utils/dom/externalLink";
|
|
157
502
|
import { sanitizeMarkdownHtml } from "../../utils/markdown/sanitize";
|
|
158
503
|
import { pluginEndpoints } from "../api";
|
|
159
504
|
import { buildRouteUrl } from "../meta-types";
|
|
160
505
|
import type { SkillsEndpoints } from "./definition";
|
|
506
|
+
import {
|
|
507
|
+
categorizeSkill,
|
|
508
|
+
loadCollapsedSections,
|
|
509
|
+
persistCollapsedSections,
|
|
510
|
+
loadRepoCollapsed,
|
|
511
|
+
persistRepoCollapsed,
|
|
512
|
+
pickInitialSelection,
|
|
513
|
+
type SkillSectionKey,
|
|
514
|
+
} from "./categories";
|
|
161
515
|
|
|
162
516
|
const { t } = useI18n();
|
|
163
517
|
|
|
@@ -177,7 +531,34 @@ const props = defineProps<{
|
|
|
177
531
|
// remove rows without waiting for a fresh tool_result push.
|
|
178
532
|
// Re-seeded whenever the underlying tool result changes.
|
|
179
533
|
const skills = ref<SkillSummary[]>(props.selectedResult?.data?.skills ?? []);
|
|
180
|
-
|
|
534
|
+
|
|
535
|
+
// Collapsed-section state for the sidebar (active / catalog). Persisted
|
|
536
|
+
// to localStorage so each user's preference survives reloads.
|
|
537
|
+
// shallowRef because we always replace the Set wholesale (toggleSection
|
|
538
|
+
// builds a fresh Set), avoiding the deep-proxy that ref() would create.
|
|
539
|
+
const collapsedSections = shallowRef<Set<SkillSectionKey>>(loadCollapsedSections());
|
|
540
|
+
|
|
541
|
+
// Active skills, alphabetised. Provenance (system / project / user) is
|
|
542
|
+
// shown as a per-row badge via sourceMeta, not as its own collapsible
|
|
543
|
+
// group — the sidebar groups by section, not by provenance.
|
|
544
|
+
const activeSkills = computed(() => [...skills.value].sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name)));
|
|
545
|
+
|
|
546
|
+
function isSectionOpen(key: SkillSectionKey): boolean {
|
|
547
|
+
return !collapsedSections.value.has(key);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function toggleSection(key: SkillSectionKey): void {
|
|
551
|
+
const next = new Set(collapsedSections.value);
|
|
552
|
+
if (next.has(key)) {
|
|
553
|
+
next.delete(key);
|
|
554
|
+
} else {
|
|
555
|
+
next.add(key);
|
|
556
|
+
}
|
|
557
|
+
collapsedSections.value = next;
|
|
558
|
+
persistCollapsedSections(next);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const selectedName = ref<string | null>(pickInitialSelection(activeSkills.value, collapsedSections.value));
|
|
181
562
|
const detail = ref<SkillDetail | null>(null);
|
|
182
563
|
const detailLoading = ref(false);
|
|
183
564
|
const detailError = ref<string | null>(null);
|
|
@@ -195,23 +576,435 @@ const renderedBody = computed(() => {
|
|
|
195
576
|
return sanitizeMarkdownHtml(marked(body) as string);
|
|
196
577
|
});
|
|
197
578
|
|
|
579
|
+
// Edit/Delete follows the backend writer contract (writer.ts rejects
|
|
580
|
+
// only source === "user"), NOT the mc- name heuristic. Under #1335
|
|
581
|
+
// PR-A the launcher syncs presets to data/skills/catalog/preset/ and
|
|
582
|
+
// leaves .claude/skills/ untouched, so a ★-starred mc- preset is a
|
|
583
|
+
// normal project-scope skill — gating it read-only by name would make
|
|
584
|
+
// activation one-way (no un-star / edit from /skills). The mc- =
|
|
585
|
+
// "system" classification survives only as the provenance badge.
|
|
586
|
+
const isSelectedEditable = computed(() => detail.value?.source === "project");
|
|
587
|
+
|
|
588
|
+
const listError = ref<string | null>(null);
|
|
589
|
+
|
|
590
|
+
const endpoints = pluginEndpoints<SkillsEndpoints>("skills");
|
|
591
|
+
|
|
592
|
+
// Catalog state (#1335 PR-B). Loaded on mount + after a successful
|
|
593
|
+
// star so the row updates from "★ Star" → "★ Starred".
|
|
594
|
+
// `catalogActioningKey` (declared below) disables the button
|
|
595
|
+
// mid-request to prevent double-clicks across Star / Run once.
|
|
596
|
+
type CatalogSource = "preset" | "external";
|
|
597
|
+
interface CatalogEntry {
|
|
598
|
+
slug: string;
|
|
599
|
+
name: string;
|
|
600
|
+
description: string;
|
|
601
|
+
source: CatalogSource;
|
|
602
|
+
alreadyActive: boolean;
|
|
603
|
+
// External entries only — identify the source repo + skill folder
|
|
604
|
+
// so star / preview / run-once can address them (slug alone is the
|
|
605
|
+
// derived activeId, not enough to locate the catalog copy).
|
|
606
|
+
repoId?: string;
|
|
607
|
+
skillFolder?: string;
|
|
608
|
+
repoUrl?: string;
|
|
609
|
+
}
|
|
610
|
+
interface CatalogDetail {
|
|
611
|
+
slug: string;
|
|
612
|
+
source: CatalogSource;
|
|
613
|
+
description: string;
|
|
614
|
+
body: string;
|
|
615
|
+
}
|
|
616
|
+
interface ExternalRepo {
|
|
617
|
+
repoId: string;
|
|
618
|
+
url: string;
|
|
619
|
+
subpath?: string;
|
|
620
|
+
sha: string;
|
|
621
|
+
installedAt: string;
|
|
622
|
+
}
|
|
623
|
+
interface ExternalSuggestion {
|
|
624
|
+
url: string;
|
|
625
|
+
subpath?: string;
|
|
626
|
+
displayName: string;
|
|
627
|
+
description: string;
|
|
628
|
+
license?: string;
|
|
629
|
+
}
|
|
630
|
+
const catalogPresets = ref<CatalogEntry[]>([]);
|
|
631
|
+
const catalogExternal = ref<CatalogEntry[]>([]);
|
|
632
|
+
const catalogRepos = ref<ExternalRepo[]>([]);
|
|
633
|
+
const catalogError = ref<string | null>(null);
|
|
634
|
+
// Per-repo collapse set (repoId ∈ set ⇒ collapsed). shallowRef: the
|
|
635
|
+
// Set is replaced wholesale on toggle.
|
|
636
|
+
const repoCollapsed = shallowRef<Set<string>>(loadRepoCollapsed());
|
|
637
|
+
// Add-repo modal state.
|
|
638
|
+
const addRepoOpen = ref(false);
|
|
639
|
+
const addRepoUrl = ref("");
|
|
640
|
+
const addRepoSubpath = ref("");
|
|
641
|
+
const addRepoError = ref<string | null>(null);
|
|
642
|
+
const addRepoBusy = ref(false);
|
|
643
|
+
const suggestions = ref<ExternalSuggestion[]>([]);
|
|
644
|
+
// Which suggestion the user picked: drives the form prefill + the
|
|
645
|
+
// "expanded description" / highlight. Selecting never installs —
|
|
646
|
+
// install stays explicit (Install button / Enter in the URL field).
|
|
647
|
+
const selectedSuggestionUrl = ref<string | null>(null);
|
|
648
|
+
const uninstallingRepoId = ref<string | null>(null);
|
|
649
|
+
const updatingRepoId = ref<string | null>(null);
|
|
650
|
+
// Single in-flight gate covers Star / Run once on the selected
|
|
651
|
+
// entry so a slow request doesn't let the user fire a second
|
|
652
|
+
// action mid-flight.
|
|
653
|
+
const catalogActioningKey = ref<string | null>(null);
|
|
654
|
+
// Right-pane selection for a catalog entry (mutually exclusive
|
|
655
|
+
// with `selectedName` — picking one clears the other).
|
|
656
|
+
const selectedCatalog = ref<CatalogEntry | null>(null);
|
|
657
|
+
const catalogDetail = ref<CatalogDetail | null>(null);
|
|
658
|
+
const catalogDetailLoading = ref(false);
|
|
659
|
+
// `appApi` is also referenced lower down by the existing `runSkill`
|
|
660
|
+
// (slash-command invocation for active skills); hoisting one
|
|
661
|
+
// declaration so the catalog handlers don't need their own lookup.
|
|
662
|
+
const catalogAppApi = useAppApi();
|
|
663
|
+
|
|
664
|
+
const catalogRenderedBody = computed(() => {
|
|
665
|
+
const body = catalogDetail.value?.body;
|
|
666
|
+
if (!body) return "";
|
|
667
|
+
return sanitizeMarkdownHtml(marked(body) as string);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// External catalog entries grouped under their repo, in the repo
|
|
671
|
+
// order returned by `/external/repos`. Repos with zero discoverable
|
|
672
|
+
// entries still render (header + empty state) so an install that
|
|
673
|
+
// found nothing is visible rather than silently absent.
|
|
674
|
+
const externalGroups = computed<{ repo: ExternalRepo; entries: CatalogEntry[] }[]>(() =>
|
|
675
|
+
catalogRepos.value.map((repo) => ({
|
|
676
|
+
repo,
|
|
677
|
+
entries: catalogExternal.value
|
|
678
|
+
.filter((entry) => entry.repoId === repo.repoId)
|
|
679
|
+
.sort((leftEntry, rightEntry) => leftEntry.slug.localeCompare(rightEntry.slug)),
|
|
680
|
+
})),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
function repoLabel(repo: ExternalRepo): string {
|
|
684
|
+
// `https://github.com/owner/repo` → `owner/repo`; fall back to the
|
|
685
|
+
// repoId if the URL is somehow unparseable.
|
|
686
|
+
const match = /github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(repo.url);
|
|
687
|
+
return match ? match[1] : repo.repoId;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function isRepoOpen(repoId: string): boolean {
|
|
691
|
+
return !repoCollapsed.value.has(repoId);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function toggleRepo(repoId: string): void {
|
|
695
|
+
const next = new Set(repoCollapsed.value);
|
|
696
|
+
if (next.has(repoId)) {
|
|
697
|
+
next.delete(repoId);
|
|
698
|
+
} else {
|
|
699
|
+
next.add(repoId);
|
|
700
|
+
}
|
|
701
|
+
repoCollapsed.value = next;
|
|
702
|
+
persistRepoCollapsed(next);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Body/query shape for star + preview: external entries are keyed by
|
|
706
|
+
// (repoId, skillFolder); presets by slug. Centralised so the two call
|
|
707
|
+
// sites can't drift.
|
|
708
|
+
function catalogActionParams(entry: CatalogEntry): Record<string, string> {
|
|
709
|
+
if (entry.source === "external" && entry.repoId && entry.skillFolder) {
|
|
710
|
+
return { source: "external", repoId: entry.repoId, skillFolder: entry.skillFolder };
|
|
711
|
+
}
|
|
712
|
+
return { source: entry.source, slug: entry.slug };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Stable UI identity. External `slug` is the backend-derived
|
|
716
|
+
// `<owner>-<skillFolder>` activeId — lossy + owner-prefixed, so two
|
|
717
|
+
// external entries can collide (dup Vue keys / testids, wrong row
|
|
718
|
+
// highlighted, shared in-flight lock, stale preview guard passing for
|
|
719
|
+
// the wrong item). `(repoId, skillFolder)` is the unique stable key;
|
|
720
|
+
// presets keep their already-unique slug.
|
|
721
|
+
function entryKey(entry: CatalogEntry): string {
|
|
722
|
+
if (entry.source === "external" && entry.repoId && entry.skillFolder) {
|
|
723
|
+
return `${entry.repoId}/${entry.skillFolder}`;
|
|
724
|
+
}
|
|
725
|
+
return entry.slug;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const selectedCatalogKey = computed(() => (selectedCatalog.value ? entryKey(selectedCatalog.value) : null));
|
|
729
|
+
|
|
730
|
+
// Visual key for the provenance badge on every active row + the
|
|
731
|
+
// preset rows. Provenance is derived via categorizeSkill (NOT the raw
|
|
732
|
+
// `source`, which can't express "system") so the badge stays
|
|
733
|
+
// consistent with sectionLegend and the edit gate:
|
|
734
|
+
// - system `mc-` bundled, read-only — launcher-owned
|
|
735
|
+
// - project `<workspace>/.claude/skills/` — this workspace only
|
|
736
|
+
// - user `~/.claude/skills/` — global across workspaces
|
|
737
|
+
// - preset catalog (not yet ★ Starred) — launcher-managed
|
|
738
|
+
// Icons + colours are deliberately monochromatic except for the
|
|
739
|
+
// preset case where we hint "library / shelf" with the inventory
|
|
740
|
+
// glyph. The yellow ★ for "starred" is rendered separately so the
|
|
741
|
+
// scope badge stays semantically about provenance, not state.
|
|
742
|
+
interface SourceMeta {
|
|
743
|
+
icon: string;
|
|
744
|
+
title: string;
|
|
745
|
+
colour: string;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function skillBadge(skill: SkillSummary): SourceMeta {
|
|
749
|
+
const provenance = categorizeSkill(skill);
|
|
750
|
+
if (provenance === "system") {
|
|
751
|
+
return { icon: "lock", title: t("pluginManageSkills.sourceSystemTitle"), colour: "text-gray-500" };
|
|
752
|
+
}
|
|
753
|
+
if (provenance === "user") {
|
|
754
|
+
return { icon: "home", title: t("pluginManageSkills.sourceUserTitle"), colour: "text-blue-500" };
|
|
755
|
+
}
|
|
756
|
+
return { icon: "folder", title: t("pluginManageSkills.sourceProjectTitle"), colour: "text-green-600" };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const presetSourceMeta = computed<SourceMeta>(() => ({
|
|
760
|
+
icon: "inventory_2",
|
|
761
|
+
title: t("pluginManageSkills.sourcePresetTitle"),
|
|
762
|
+
colour: "text-gray-400",
|
|
763
|
+
}));
|
|
764
|
+
|
|
198
765
|
// Reset the selection when the tool result is replaced (e.g. the
|
|
199
766
|
// user opens a newer `manageSkills` invocation from the sidebar).
|
|
767
|
+
// Lives after the catalog refs so source-order use-before-define
|
|
768
|
+
// is satisfied — the closure runs at watch-fire time, not at
|
|
769
|
+
// module-eval time, but the lint rule is structural.
|
|
200
770
|
watch(
|
|
201
771
|
() => props.selectedResult?.uuid,
|
|
202
772
|
() => {
|
|
203
773
|
skills.value = props.selectedResult?.data?.skills ?? [];
|
|
204
|
-
selectedName.value =
|
|
774
|
+
selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
|
|
775
|
+
selectedCatalog.value = null;
|
|
776
|
+
catalogDetail.value = null;
|
|
777
|
+
catalogDetailLoading.value = false;
|
|
778
|
+
catalogActioningKey.value = null;
|
|
779
|
+
catalogError.value = null;
|
|
780
|
+
addRepoOpen.value = false;
|
|
781
|
+
addRepoError.value = null;
|
|
782
|
+
selectedSuggestionUrl.value = null;
|
|
783
|
+
uninstallingRepoId.value = null;
|
|
784
|
+
updatingRepoId.value = null;
|
|
205
785
|
},
|
|
206
786
|
);
|
|
207
787
|
|
|
208
|
-
|
|
788
|
+
async function loadCatalog(): Promise<void> {
|
|
789
|
+
const response = await apiGet<{ entries: CatalogEntry[] }>(endpoints.catalogList.url);
|
|
790
|
+
if (!response.ok) {
|
|
791
|
+
catalogError.value = t("pluginManageSkills.errCatalogListFailed", { error: response.error });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
catalogError.value = null;
|
|
795
|
+
if (Array.isArray(response.data.entries)) {
|
|
796
|
+
catalogPresets.value = response.data.entries.filter((entry) => entry.source === "preset");
|
|
797
|
+
catalogExternal.value = response.data.entries.filter((entry) => entry.source === "external");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
209
800
|
|
|
210
|
-
|
|
801
|
+
async function loadExternalRepos(): Promise<void> {
|
|
802
|
+
const response = await apiGet<{ repos: ExternalRepo[] }>(endpoints.externalReposList.url);
|
|
803
|
+
if (!response.ok) {
|
|
804
|
+
catalogError.value = t("pluginManageSkills.errCatalogRepoListFailed", { error: response.error });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (Array.isArray(response.data.repos)) catalogRepos.value = response.data.repos;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function loadSuggestions(): Promise<void> {
|
|
811
|
+
const response = await apiGet<{ suggestions: ExternalSuggestion[] }>(endpoints.externalSuggestions.url);
|
|
812
|
+
if (response.ok && Array.isArray(response.data.suggestions)) suggestions.value = response.data.suggestions;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function openAddRepo(): void {
|
|
816
|
+
addRepoUrl.value = "";
|
|
817
|
+
addRepoSubpath.value = "";
|
|
818
|
+
addRepoError.value = null;
|
|
819
|
+
selectedSuggestionUrl.value = null;
|
|
820
|
+
addRepoOpen.value = true;
|
|
821
|
+
if (suggestions.value.length === 0) void loadSuggestions();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Pick a suggestion → prefill the form so the user can review and
|
|
825
|
+
// then press Install. Deliberately does NOT install (avoids the
|
|
826
|
+
// accidental one-click install footgun).
|
|
827
|
+
function selectSuggestion(suggestion: ExternalSuggestion): void {
|
|
828
|
+
addRepoUrl.value = suggestion.url;
|
|
829
|
+
addRepoSubpath.value = suggestion.subpath ?? "";
|
|
830
|
+
addRepoError.value = null;
|
|
831
|
+
selectedSuggestionUrl.value = suggestion.url;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function installRepo(url: string, subpath?: string): Promise<void> {
|
|
835
|
+
if (addRepoBusy.value) return;
|
|
836
|
+
const trimmedUrl = url.trim();
|
|
837
|
+
if (trimmedUrl.length === 0) {
|
|
838
|
+
addRepoError.value = t("pluginManageSkills.errCatalogRepoInvalidUrl");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
addRepoBusy.value = true;
|
|
842
|
+
addRepoError.value = null;
|
|
843
|
+
try {
|
|
844
|
+
const trimmedSubpath = subpath?.trim();
|
|
845
|
+
const body: Record<string, string> = { url: trimmedUrl };
|
|
846
|
+
if (trimmedSubpath) body.subpath = trimmedSubpath;
|
|
847
|
+
const response = await apiPost<{ installed: true; repoId: string }>(endpoints.externalReposInstall.url, body);
|
|
848
|
+
if (!response.ok) {
|
|
849
|
+
addRepoError.value = t("pluginManageSkills.errCatalogRepoInstallFailed", { error: response.error });
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
addRepoOpen.value = false;
|
|
853
|
+
await Promise.all([loadExternalRepos(), loadCatalog()]);
|
|
854
|
+
} finally {
|
|
855
|
+
addRepoBusy.value = false;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function uninstallRepo(repoId: string): Promise<void> {
|
|
860
|
+
if (uninstallingRepoId.value !== null) return;
|
|
861
|
+
if (typeof window !== "undefined" && !window.confirm(t("pluginManageSkills.catalogUninstallConfirm"))) return;
|
|
862
|
+
uninstallingRepoId.value = repoId;
|
|
863
|
+
try {
|
|
864
|
+
const response = await apiDelete<{ uninstalled: true }>(buildRouteUrl(endpoints.externalReposRemove, { repoId }));
|
|
865
|
+
if (!response.ok) {
|
|
866
|
+
catalogError.value = t("pluginManageSkills.errCatalogRepoUninstallFailed", { error: response.error });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
catalogError.value = null;
|
|
870
|
+
if (selectedCatalog.value?.repoId === repoId) {
|
|
871
|
+
selectedCatalog.value = null;
|
|
872
|
+
catalogDetail.value = null;
|
|
873
|
+
}
|
|
874
|
+
// Starred copies survive uninstall (backend-guaranteed, C1) — pull
|
|
875
|
+
// the active list so any starred-from-this-repo rows stay visible.
|
|
876
|
+
await Promise.all([loadExternalRepos(), loadCatalog(), refreshActiveList()]);
|
|
877
|
+
} finally {
|
|
878
|
+
uninstallingRepoId.value = null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// "Update" == re-install with the repo's recorded url/subpath. C1's
|
|
883
|
+
// install path re-fetches upstream HEAD, wipes + re-copies the
|
|
884
|
+
// catalog dir, and rewrites `.source.json` with the new SHA. Starred
|
|
885
|
+
// copies under `.claude/skills/` are untouched (catalog-layer only).
|
|
886
|
+
async function updateRepo(repo: ExternalRepo): Promise<void> {
|
|
887
|
+
if (updatingRepoId.value !== null) return;
|
|
888
|
+
updatingRepoId.value = repo.repoId;
|
|
889
|
+
// try/finally so the in-flight gate always clears even if the
|
|
890
|
+
// request throws — otherwise the button stays disabled forever
|
|
891
|
+
// (same hardening as runOnceCatalogEntry, Codex review #1374).
|
|
892
|
+
try {
|
|
893
|
+
const body: Record<string, string> = { url: repo.url };
|
|
894
|
+
if (repo.subpath) body.subpath = repo.subpath;
|
|
895
|
+
const response = await apiPost<{ installed: true; repoId: string }>(endpoints.externalReposInstall.url, body);
|
|
896
|
+
if (!response.ok) {
|
|
897
|
+
catalogError.value = t("pluginManageSkills.errCatalogRepoInstallFailed", { error: response.error });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
catalogError.value = null;
|
|
901
|
+
await Promise.all([loadExternalRepos(), loadCatalog()]);
|
|
902
|
+
} finally {
|
|
903
|
+
updatingRepoId.value = null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function refreshActiveList(): Promise<void> {
|
|
908
|
+
// Mirrors the onMounted fetch so the left-column list reflects the
|
|
909
|
+
// newly-starred skill without waiting for the next manageSkills
|
|
910
|
+
// tool result. Errors here are non-fatal — the catalog state is
|
|
911
|
+
// the source of truth for the "Starred" badge.
|
|
912
|
+
const response = await apiGet<{ skills: SkillSummary[] }>(endpoints.list.url);
|
|
913
|
+
if (response.ok && Array.isArray(response.data.skills)) {
|
|
914
|
+
skills.value = response.data.skills;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function starCatalogEntry(entry: CatalogEntry): Promise<void> {
|
|
919
|
+
if (entry.alreadyActive) return;
|
|
920
|
+
catalogActioningKey.value = entryKey(entry);
|
|
921
|
+
const response = await apiPost<{ starred: true; slug: string }>(endpoints.catalogStar.url, catalogActionParams(entry));
|
|
922
|
+
catalogActioningKey.value = null;
|
|
923
|
+
if (!response.ok) {
|
|
924
|
+
catalogError.value = t("pluginManageSkills.errCatalogStarFailed", { error: response.error });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
catalogError.value = null;
|
|
928
|
+
// Refresh both lists so the row flips to "Starred" and the new
|
|
929
|
+
// active entry shows up in the left column.
|
|
930
|
+
await Promise.all([loadCatalog(), refreshActiveList()]);
|
|
931
|
+
// Reconcile the right-pane selection with the refreshed list so
|
|
932
|
+
// its `alreadyActive` flag reflects reality without forcing the
|
|
933
|
+
// user to re-click.
|
|
934
|
+
if (selectedCatalog.value && entryKey(selectedCatalog.value) === entryKey(entry)) {
|
|
935
|
+
const pool = entry.source === "external" ? catalogExternal.value : catalogPresets.value;
|
|
936
|
+
const updated = pool.find((candidate) => entryKey(candidate) === entryKey(entry));
|
|
937
|
+
if (updated) selectedCatalog.value = updated;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function fetchCatalogDetail(entry: CatalogEntry): Promise<CatalogDetail | null> {
|
|
942
|
+
const response = await apiGet<{ detail: CatalogDetail }>(endpoints.catalogPreview.url, catalogActionParams(entry));
|
|
943
|
+
if (!response.ok) {
|
|
944
|
+
catalogError.value = t("pluginManageSkills.errCatalogPreviewFailed", { error: response.error });
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
catalogError.value = null;
|
|
948
|
+
return response.data.detail;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function selectActiveSkill(name: string): void {
|
|
952
|
+
// Active and catalog selections are mutually exclusive — picking
|
|
953
|
+
// one clears the other so the right pane has a single source of
|
|
954
|
+
// truth.
|
|
955
|
+
selectedCatalog.value = null;
|
|
956
|
+
catalogDetail.value = null;
|
|
957
|
+
selectedName.value = name;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function selectCatalogEntry(entry: CatalogEntry): Promise<void> {
|
|
961
|
+
selectedName.value = null;
|
|
962
|
+
selectedCatalog.value = entry;
|
|
963
|
+
catalogDetail.value = null;
|
|
964
|
+
catalogDetailLoading.value = true;
|
|
965
|
+
const keyAtRequest = entryKey(entry);
|
|
966
|
+
const fetched = await fetchCatalogDetail(entry);
|
|
967
|
+
// Selection may have changed while the request was in flight —
|
|
968
|
+
// drop the response if so (same race-condition guard the active-
|
|
969
|
+
// skill detail watcher uses). Identity is the (repoId, skillFolder)
|
|
970
|
+
// composite for external entries, not the lossy slug.
|
|
971
|
+
if (!selectedCatalog.value || entryKey(selectedCatalog.value) !== keyAtRequest) return;
|
|
972
|
+
catalogDetailLoading.value = false;
|
|
973
|
+
if (fetched !== null) catalogDetail.value = fetched;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function runOnceCatalogEntry(entry: CatalogEntry): Promise<void> {
|
|
977
|
+
// Use the already-fetched detail when the entry is the current
|
|
978
|
+
// right-pane selection (the common case — user reads body, then
|
|
979
|
+
// clicks Run once). Falls back to a fresh fetch when the click
|
|
980
|
+
// somehow lands without a prior selection (defensive — the right
|
|
981
|
+
// pane is the only place Run once is exposed today).
|
|
982
|
+
//
|
|
983
|
+
// The shared in-flight gate is held for the whole flow so a
|
|
984
|
+
// rapid double-click can't enqueue two `startNewChat` calls
|
|
985
|
+
// and spawn duplicate sessions. (Codex review on PR #1374.)
|
|
986
|
+
catalogActioningKey.value = entryKey(entry);
|
|
987
|
+
try {
|
|
988
|
+
const isSelectedEntry = selectedCatalog.value !== null && entryKey(selectedCatalog.value) === entryKey(entry) && catalogDetail.value !== null;
|
|
989
|
+
const body = isSelectedEntry && catalogDetail.value !== null ? catalogDetail.value.body : (await fetchCatalogDetail(entry))?.body;
|
|
990
|
+
if (!body || !body.trim()) {
|
|
991
|
+
catalogError.value = t("pluginManageSkills.errCatalogRunOnceEmpty");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
catalogAppApi.startNewChat(body);
|
|
995
|
+
} finally {
|
|
996
|
+
catalogActioningKey.value = null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
211
999
|
|
|
212
1000
|
// Standalone mode: if no selectedResult was passed, fetch the skill
|
|
213
1001
|
// list from the API on mount so the view is populated.
|
|
214
1002
|
onMounted(async () => {
|
|
1003
|
+
// Always load the catalog so the section appears even when the
|
|
1004
|
+
// view was opened from a tool result (which only carries the
|
|
1005
|
+
// active list). External repos load in parallel — failure of one
|
|
1006
|
+
// doesn't block the other (each sets its own inline error).
|
|
1007
|
+
await Promise.all([loadCatalog(), loadExternalRepos()]);
|
|
215
1008
|
if (props.selectedResult || skills.value.length > 0) return;
|
|
216
1009
|
const response = await apiGet<{ skills: SkillSummary[] }>(endpoints.list.url);
|
|
217
1010
|
if (!response.ok) {
|
|
@@ -220,7 +1013,7 @@ onMounted(async () => {
|
|
|
220
1013
|
}
|
|
221
1014
|
if (Array.isArray(response.data.skills)) {
|
|
222
1015
|
skills.value = response.data.skills;
|
|
223
|
-
selectedName.value =
|
|
1016
|
+
selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
|
|
224
1017
|
}
|
|
225
1018
|
});
|
|
226
1019
|
|
|
@@ -332,7 +1125,16 @@ async function deleteSkill(): Promise<void> {
|
|
|
332
1125
|
if (idx >= 0) {
|
|
333
1126
|
skills.value.splice(idx, 1);
|
|
334
1127
|
}
|
|
335
|
-
selectedName.value =
|
|
1128
|
+
selectedName.value = pickInitialSelection(activeSkills.value, collapsedSections.value);
|
|
336
1129
|
detail.value = null;
|
|
1130
|
+
// Refresh the catalog so a deleted star reverts to ☆ Star.
|
|
1131
|
+
// `alreadyActive` is computed from disk at list time — without
|
|
1132
|
+
// this call the badge + right-pane state would lag until the
|
|
1133
|
+
// next mount. (#1335 PR-B2 follow-up.)
|
|
1134
|
+
await loadCatalog();
|
|
1135
|
+
if (selectedCatalog.value?.slug === name) {
|
|
1136
|
+
const refreshed = catalogPresets.value.find((candidate) => candidate.slug === name);
|
|
1137
|
+
if (refreshed) selectedCatalog.value = refreshed;
|
|
1138
|
+
}
|
|
337
1139
|
}
|
|
338
1140
|
</script>
|