Skip to content

Commit 6f38288

Browse files
committed
fix: address PR review findings on the settings route
Drop disabled plugin skills at discovery indexing, not only at register, so getPluginSkill can no longer return one. Refetch subagents on every visit to their page, since the cached list belongs to the session whose working directory produced it. Match disabled skill names case-insensitively in the web settings, as the core does. Keep a configured default model visible when the catalog no longer offers it. Sum the combined desktop download counter across both release repositories like the per-platform ones.
1 parent 04aa1d8 commit 6f38288

11 files changed

Lines changed: 318 additions & 37 deletions

File tree

.github/workflows/desktop-download-badges.yml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jobs:
5050
gh api --paginate "repos/${repo}/releases"
5151
done | jq -s 'add' > releases.json
5252
53+
# An empty suffix matches every asset, which is what the combined
54+
# counter wants; `endswith("")` is true for any string.
5355
write_badge() {
5456
local suffix="$1" label="$2" out="$3"
5557
local total
@@ -70,18 +72,23 @@ jobs:
7072
7173
write_badge '.dmg' 'macOS .dmg' desktop-dmg.json
7274
write_badge '.exe' 'Windows .exe' desktop-exe.json
75+
# The shields `github/downloads/.../total` route reads one repository,
76+
# so the combined counter has to be summed here like the other two.
77+
write_badge '' 'desktop' desktop-total.json
7378
7479
rm -f releases.json
7580
7681
- name: Publish if the counts moved
7782
run: |
7883
set -euo pipefail
79-
if git diff --quiet -- desktop-dmg.json desktop-exe.json; then
84+
git config user.name 'github-actions[bot]'
85+
git config user.email 'github-actions[bot]@users.noreply.github.com'
86+
# Stage before comparing: a document added in this run is untracked,
87+
# and `git diff` on an untracked path reports no change at all.
88+
git add desktop-dmg.json desktop-exe.json desktop-total.json
89+
if git diff --cached --quiet; then
8090
echo 'Counts unchanged; nothing to publish.'
8191
exit 0
8292
fi
83-
git config user.name 'github-actions[bot]'
84-
git config user.email 'github-actions[bot]@users.noreply.github.com'
85-
git add desktop-dmg.json desktop-exe.json
8693
git commit -m 'chore: refresh desktop download counts'
8794
git push origin badges

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
[![npm version](https://img.shields.io/npm/v/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=CB3837&label=pythinker-code)](https://www.npmjs.com/package/@pymodel/pythinker-code)
1313
[![Downloads](https://img.shields.io/npm/dm/@pymodel/pythinker-code?style=for-the-badge&logo=npm&logoColor=white&color=2b89ff&label=downloads)](https://www.npmjs.com/package/@pymodel/pythinker-code)
14-
[![Desktop downloads](https://img.shields.io/github/downloads/PyModel/pythinker-desktop-releases/total?style=for-the-badge&logo=github&logoColor=white&label=desktop&color=4D6BFE)](https://github.com/PyModel/pythinker-desktop-releases/releases)
14+
[![Desktop downloads](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-total.json&style=for-the-badge&logo=github&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases)
1515
[![macOS .dmg](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-dmg.json&style=for-the-badge&logo=apple&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest)
1616
[![Windows .exe](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FPyModel%2Fpythinker-code%2Fbadges%2Fdesktop-exe.json&style=for-the-badge&logo=windows&logoColor=white)](https://github.com/PyModel/pythinker-desktop-releases/releases/latest)
1717
[![Node.js](https://img.shields.io/badge/Node.js-26%2B-339933?style=for-the-badge&logo=nodedotjs&logoColor=white)](https://github.com/PyModel/pythinker-code/blob/main/package.json)

apps/pythinker-web/src/App.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,11 @@ const showAddWorkspace = ref(false);
611611
const showStatusPanel = ref(false);
612612
const showSettings = ref(false);
613613
614-
const { activeTab: activeSettingsTab, setTab: selectSettingsTab } = useSettingsNav({
614+
const {
615+
activeTab: activeSettingsTab,
616+
setTab: selectSettingsTab,
617+
refreshActiveTab: refreshSettingsTab,
618+
} = useSettingsNav({
615619
counts: {
616620
connectors: () => client.connectors.value.length,
617621
plugins: () => client.plugins.value.length,
@@ -622,8 +626,16 @@ const { activeTab: activeSettingsTab, setTab: selectSettingsTab } = useSettingsN
622626
onLoadSubagents: () => { void client.loadSubagents(); },
623627
});
624628
629+
function openSettings(): void {
630+
showSettings.value = true;
631+
// The active tab persists across visits, so its data has to be refetched on
632+
// open — the session it was loaded for may no longer be the active one.
633+
refreshSettingsTab();
634+
}
635+
625636
function toggleSettings(): void {
626-
showSettings.value = !showSettings.value;
637+
if (showSettings.value) showSettings.value = false;
638+
else openSettings();
627639
}
628640
629641
function loginFromSettings(): void {
@@ -961,7 +973,7 @@ function openPr(url: string): void {
961973
@rename-workspace="(id, name) => client.renameWorkspace(id, name)"
962974
@delete-workspace="(id) => client.deleteWorkspace(id)"
963975
@select-workspaces="handleSelectWorkspaces"
964-
@open-settings="showSettings = true"
976+
@open-settings="openSettings"
965977
@close-settings="showSettings = false"
966978
@select-settings-tab="selectSettingsTab($event)"
967979
@collapse="toggleSidebarCollapse"

apps/pythinker-web/src/components/settings/pages/AgentPage.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ const modelOptions = computed<ModelOption[]>(() => {
3939
return Array.from(byId.values());
4040
});
4141
42+
// A default the catalog no longer offers still has to appear, or the browser
43+
// falls back to the first option and shows a model that was never saved.
44+
const unlistedDefaultModel = computed<string | undefined>(() => {
45+
const configured = props.config?.defaultModel;
46+
if (configured === undefined || configured === '') return undefined;
47+
return modelOptions.value.some((model) => model.id === configured) ? undefined : configured;
48+
});
49+
4250
const modelGroups = computed<Array<{ provider: string; options: ModelOption[] }>>(() => {
4351
const map = new Map<string, ModelOption[]>();
4452
for (const option of modelOptions.value) {
@@ -122,6 +130,7 @@ function toggleConfigBoolean(key: 'defaultThinking' | 'defaultPlanMode' | 'merge
122130
@change="setDefaultModel"
123131
>
124132
<option v-if="!config.defaultModel" value="" disabled>{{ t('settings.noDefaultModel') }}</option>
133+
<option v-if="unlistedDefaultModel" :value="unlistedDefaultModel">{{ unlistedDefaultModel }}</option>
125134
<optgroup v-for="group in modelGroups" :key="group.provider" :label="group.provider">
126135
<option v-for="model in group.options" :key="model.id" :value="model.id">{{ model.label }}</option>
127136
</optgroup>

apps/pythinker-web/src/components/settings/pages/SkillsPage.vue

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,22 @@ const skillGroups = computed(() => {
3535
.toSorted((a, b) => a.source.localeCompare(b.source));
3636
});
3737
38-
const disabledSkills = computed(() => new Set(props.config?.disabledSkills ?? []));
38+
// The core matches disabled names case-insensitively, so the page has to as
39+
// well — otherwise a config entry cased differently from the catalog reads as
40+
// enabled here and the switch never turns it back on.
41+
const disabledSkills = computed(() => props.config?.disabledSkills ?? []);
3942
const skillCount = computed(() => skillGroups.value.reduce((sum, group) => sum + group.skills.length, 0));
4043
4144
function isSkillEnabled(name: string): boolean {
42-
return !disabledSkills.value.has(name);
45+
const key = name.toLowerCase();
46+
return !disabledSkills.value.some((entry) => entry.toLowerCase() === key);
4347
}
4448
4549
function toggleSkill(name: string): void {
46-
const next = new Set(disabledSkills.value);
47-
if (next.has(name)) next.delete(name);
48-
else next.add(name);
49-
emit('updateConfig', { disabledSkills: [...next].sort() });
50+
const key = name.toLowerCase();
51+
const next = disabledSkills.value.filter((entry) => entry.toLowerCase() !== key);
52+
if (next.length === disabledSkills.value.length) next.push(name);
53+
emit('updateConfig', { disabledSkills: next.toSorted() });
5054
}
5155
</script>
5256

apps/pythinker-web/src/composables/useSettingsNav.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,24 @@ type UseSettingsNavOptions = {
5757
export function useSettingsNav(options: UseSettingsNavOptions) {
5858
const activeTab = shallowRef<SettingsTab>('general');
5959

60-
function setTab(tab: SettingsTab): void {
60+
function loadFor(tab: SettingsTab): void {
6161
if (tab === 'connectors' && toValue(options.counts.connectors) === 0) options.onLoadConnectors();
6262
if (tab === 'plugins' && toValue(options.counts.plugins) === 0) options.onLoadPlugins();
63-
if (tab === 'subagents' && toValue(options.counts.subagents) === 0) options.onLoadSubagents();
63+
// Connectors and plugins are daemon-wide, so one load holds. Subagents are
64+
// resolved from the active session's working directory, so a cached list
65+
// belongs to whichever session was active when it loaded — always refetch.
66+
if (tab === 'subagents') options.onLoadSubagents();
67+
}
68+
69+
function setTab(tab: SettingsTab): void {
70+
loadFor(tab);
6471
activeTab.value = tab;
6572
}
6673

67-
return { activeTab, setTab };
74+
/** Call when the settings route opens; the active tab persists across visits. */
75+
function refreshActiveTab(): void {
76+
loadFor(activeTab.value);
77+
}
78+
79+
return { activeTab, setTab, refreshActiveTab };
6880
}

apps/pythinker-web/test/settings-pane.test.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { nextTick } from 'vue';
33
import { createI18n } from 'vue-i18n';
44
import { afterEach, describe, expect, it, vi } from 'vitest';
55

6+
import App from '../src/App.vue';
67
import type { AppConfig, AppConnector, AppModel, AppSession, AppSkill } from '../src/api/types';
8+
import ConversationPane from '../src/components/ConversationPane.vue';
79
import SettingsNav from '../src/components/settings/SettingsNav.vue';
810
import SettingsPane from '../src/components/settings/SettingsPane.vue';
911
import { messages } from '../src/i18n/locales';
@@ -82,9 +84,6 @@ vi.mock('../src/composables/usePythinkerWebClient', async () => {
8284
};
8385
});
8486

85-
import App from '../src/App.vue';
86-
import ConversationPane from '../src/components/ConversationPane.vue';
87-
8887
const i18n = createI18n({
8988
legacy: false,
9089
locale: 'en',
@@ -240,6 +239,35 @@ describe('settings navigation', () => {
240239
setTab('subagents');
241240
expect(onLoadSubagents).toHaveBeenCalledOnce();
242241
});
242+
243+
it('reloads subagents even when a list is already cached', () => {
244+
// The cached list belongs to whichever session was active when it loaded,
245+
// so a non-empty count must not suppress the refetch.
246+
const onLoadSubagents = vi.fn();
247+
const { setTab } = useSettingsNav({
248+
counts: { connectors: 0, plugins: 0, subagents: 3 },
249+
onLoadConnectors: vi.fn(),
250+
onLoadPlugins: vi.fn(),
251+
onLoadSubagents,
252+
});
253+
254+
setTab('subagents');
255+
expect(onLoadSubagents).toHaveBeenCalledOnce();
256+
});
257+
258+
it('reloads the persisted tab when the settings route reopens', () => {
259+
const onLoadSubagents = vi.fn();
260+
const { setTab, refreshActiveTab } = useSettingsNav({
261+
counts: { connectors: 0, plugins: 0, subagents: 3 },
262+
onLoadConnectors: vi.fn(),
263+
onLoadPlugins: vi.fn(),
264+
onLoadSubagents,
265+
});
266+
267+
setTab('subagents');
268+
refreshActiveTab();
269+
expect(onLoadSubagents).toHaveBeenCalledTimes(2);
270+
});
243271
});
244272

245273
describe('SettingsPane config controls', () => {
@@ -297,6 +325,19 @@ describe('SettingsPane desktop updates', () => {
297325
});
298326
});
299327

328+
describe('SettingsPane agent page', () => {
329+
it('keeps a configured default the catalog no longer offers', () => {
330+
// Without a matching option the browser shows its first one, which reads
331+
// as a saved default that was never chosen.
332+
const wrapper = mountPane('agent', { config: { ...config, defaultModel: 'retired/model' } });
333+
const select = wrapper.get('#settings-panel-agent select.select-field');
334+
335+
expect(select.findAll('option').map((option) => option.attributes('value')))
336+
.toContain('retired/model');
337+
expect((select.element as HTMLSelectElement).value).toBe('retired/model');
338+
});
339+
});
340+
300341
describe('SettingsPane skills page', () => {
301342
it('groups skills by source and marks the slash-only ones', () => {
302343
const panel = mountPane('skills', { skills }).get('#settings-panel-skills');
@@ -309,6 +350,23 @@ describe('SettingsPane skills page', () => {
309350
it('says so when no skill is available', () => {
310351
expect(mountPane('skills').get('#settings-panel-skills').text()).toContain('No skills are available');
311352
});
353+
354+
it('reads a disabled name that is cased differently as off, and clears it once', async () => {
355+
// The core lowercases disabled names, so a config entry cased differently
356+
// from the catalog still disables the skill and the page has to agree.
357+
const wrapper = mountPane('skills', {
358+
skills,
359+
config: { ...config, disabledSkills: ['Gen-Changesets'] },
360+
});
361+
const row = wrapper.get('#settings-panel-skills').findAll('.listing-row')
362+
.find((candidate) => candidate.text().includes('gen-changesets'));
363+
364+
expect(row?.classes()).toContain('off');
365+
366+
await row?.get('button.switch').trigger('click');
367+
368+
expect(wrapper.emitted('updateConfig')?.at(-1)).toEqual([{ disabledSkills: [] }]);
369+
});
312370
});
313371

314372
describe('SettingsPane connectors page', () => {

packages/agent-core/src/services/catalog/catalog.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,3 @@
1-
/**
2-
* `ICatalogService` — daemon-facing view of what the agent can reach beyond
3-
* skills and MCP servers: installed plugins and the subagent profiles.
4-
*
5-
* Both are read from the core process and both are global rather than
6-
* session-scoped, so they share one service instead of two near-identical
7-
* wrappers around `ICoreProcessService.rpc`.
8-
*
9-
* **CoreAPI surface used**:
10-
* - `core.rpc.listPlugins({})` → `readonly PluginSummary[]`
11-
* - `core.rpc.setPluginEnabled({id, enabled})`
12-
* - `core.rpc.listAgentProfiles({workDir})` → `AgentProfileCatalog`
13-
*
14-
* **Anti-corruption**: imports `@pymodel/agent-core` internals only for the
15-
* `createDecorator` value and the rpc payload types.
16-
*/
17-
181
import { createDecorator } from '../../di';
192
import type { AgentProfileSummary } from '../../rpc';
203
import type { PluginSummary } from '../../plugin';

packages/agent-core/src/skill/registry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ export class SessionSkillRegistry implements AgentSkillRegistry {
167167
options: { readonly replace?: boolean } = {},
168168
): void {
169169
if (skill.plugin === undefined) return;
170+
// Discovery indexes plugin skills before `register` runs, so the disabled
171+
// check has to repeat here or `getPluginSkill` would still hand one back.
172+
if (this.disabledNames.has(normalizeSkillName(skill.name))) return;
170173
const key = pluginSkillKey(skill.plugin.id, skill.name);
171174
if (options.replace === true || !this.byPluginAndName.has(key)) {
172175
this.byPluginAndName.set(key, skill);

packages/agent-core/test/skill/registry.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,27 @@ describe('disabled skills', () => {
197197

198198
expect(registry.getSkill('loop')).toBeUndefined();
199199
});
200+
201+
it('never indexes a disabled plugin skill discovered from a root', async () => {
202+
// Discovery indexes plugin skills before `register` runs, so a disabled one
203+
// stays reachable through `getPluginSkill` unless the check repeats there.
204+
const disabled = { ...makeSkill('deploy', 'extra'), plugin: { id: 'acme' } };
205+
const kept = { ...makeSkill('rollback', 'extra'), plugin: { id: 'acme' } };
206+
const registry = new SessionSkillRegistry({
207+
disabledNames: ['Deploy'],
208+
discover: async (options) => {
209+
options.onDiscoveredSkill?.(disabled);
210+
options.onDiscoveredSkill?.(kept);
211+
return [disabled, kept];
212+
},
213+
});
214+
215+
await registry.loadRoots([{ path: '/tmp/plugins', source: 'extra' }]);
216+
217+
expect(registry.getPluginSkill('acme', 'deploy')).toBeUndefined();
218+
expect(registry.getSkill('deploy')).toBeUndefined();
219+
expect(registry.getPluginSkill('acme', 'rollback')).toBeDefined();
220+
});
200221
});
201222

202223
function makeRegistry(skills: readonly SkillDefinition[]): SessionSkillRegistry {

0 commit comments

Comments
 (0)