Skip to content

Commit afb06f5

Browse files
committed
feat(web): move the session tool picker into a settings Tools page
1 parent ca7daaf commit afb06f5

10 files changed

Lines changed: 225 additions & 103 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Move the web tool picker out of the composer menu into a Tools page in settings, where the full list fits. Every tool stays on until you turn one off, and the selection still applies to the current session only.

apps/pythinker-web/src/App.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,10 @@ const {
624624
},
625625
onLoadConnectors: () => { void client.loadConnectors(); },
626626
onLoadPlugins: () => { void client.loadPlugins(); },
627+
onLoadTools: () => {
628+
const sessionId = client.activeSessionId.value;
629+
if (sessionId) void client.loadCapabilityData(sessionId);
630+
},
627631
onLoadSubagents: () => { void client.loadSubagents(); },
628632
});
629633
@@ -693,6 +697,14 @@ async function openModelPicker(): Promise<void> {
693697
}
694698
}
695699
700+
/** Narrow the active session's tool selection from the settings Tools page. */
701+
function applySessionTools(names: string[]): void {
702+
// `updateCapabilities` rolls its optimistic write back and reports the
703+
// failure itself, then rethrows; swallowing here keeps a failed write from
704+
// surfacing as an unhandled rejection.
705+
void client.updateCapabilities({ tools: names }).catch(() => undefined);
706+
}
707+
696708
async function openProviders(): Promise<void> {
697709
providersLoading.value = true;
698710
providersUnavailable.value = false;
@@ -1092,7 +1104,12 @@ function openPr(url: string): void {
10921104
:sessions="client.sessionsWithUsage.value"
10931105
:plugins="client.plugins.value"
10941106
:subagents="client.subagents.value"
1107+
:tools="client.toolsBySession.value[client.activeSessionId.value]"
1108+
:tools-loading="client.toolsLoadingBySession.value[client.activeSessionId.value] === true"
1109+
:enabled-tools="client.activeSessionCapabilities.value.tools"
1110+
:session-id="client.activeSessionId.value"
10951111
@set-plugin-enabled="client.setPluginEnabled($event.pluginId, $event.enabled)"
1112+
@set-tools="applySessionTools($event)"
10961113
@restart-connector="client.restartConnector($event)"
10971114
@set-theme="client.setTheme($event)"
10981115
@set-color-scheme="client.setColorScheme($event)"

apps/pythinker-web/src/components/CapabilityMenu.vue

Lines changed: 5 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,16 @@ const props = defineProps<{
1111
sessionId?: string;
1212
}>();
1313
14-
type MenuView = 'root' | 'tools' | 'skills' | 'plugins';
15-
type SessionCapabilities = { tools?: string[]; mcpServers?: string[] };
14+
type MenuView = 'root' | 'skills' | 'plugins';
15+
type SessionCapabilities = { mcpServers?: string[] };
1616
1717
const { t } = useI18n();
1818
const client = usePythinkerWebClient();
1919
const triggerRef = ref<HTMLButtonElement | null>(null);
2020
const open = ref(false);
2121
const view = ref<MenuView>('root');
22-
const selectedTools = ref<string[]>([]);
2322
const selectedMcpServers = ref<string[]>([]);
2423
25-
const tools = computed(() => {
26-
const sessionId = props.sessionId;
27-
return sessionId ? client.toolsBySession.value[sessionId] ?? [] : [];
28-
});
29-
const toolsLoading = computed(() => {
30-
const sessionId = props.sessionId;
31-
return sessionId ? client.toolsLoadingBySession.value[sessionId] === true : false;
32-
});
3324
const skills = computed(() =>
3425
props.sessionId === client.activeSessionId.value ? client.skills.value : [],
3526
);
@@ -47,14 +38,12 @@ const capabilities = computed<SessionCapabilities>(() =>
4738
: {},
4839
);
4940
50-
const showTools = computed(() => toolsLoading.value || tools.value.length > 0);
5141
const showSkills = computed(() => skillsLoading.value || skills.value.length > 0);
5242
const showMcp = computed(() => connectorsLoading.value || connectors.value.length > 0);
5343
const showPlugins = computed(() => pluginsLoading.value || plugins.value.length > 0);
5444
5545
const drilldownTitle = computed(() => {
5646
switch (view.value) {
57-
case 'tools': return t('capabilityMenu.tools.title');
5847
case 'skills': return t('capabilityMenu.skills.title');
5948
case 'plugins': return t('capabilityMenu.plugins.title');
6049
case 'root': return '';
@@ -63,32 +52,27 @@ const drilldownTitle = computed(() => {
6352
6453
const drilldownCount = computed(() => {
6554
switch (view.value) {
66-
case 'tools': return selectedTools.value.length;
6755
case 'skills': return skills.value.length;
6856
case 'plugins': return plugins.value.length;
6957
case 'root': return 0;
7058
}
7159
});
7260
7361
function syncSelection(): void {
74-
selectedTools.value = capabilities.value.tools !== undefined
75-
? [...capabilities.value.tools]
76-
: tools.value.map((tool) => tool.name);
7762
selectedMcpServers.value = capabilities.value.mcpServers !== undefined
7863
? [...capabilities.value.mcpServers]
7964
: connectors.value.map((server) => server.id);
8065
}
8166
8267
watch(
83-
[() => props.sessionId, tools, connectors, capabilities],
68+
[() => props.sessionId, connectors, capabilities],
8469
syncSelection,
8570
{ immediate: true },
8671
);
8772
8873
watch(
89-
[tools, toolsLoading, skills, skillsLoading, plugins, pluginsLoading],
74+
[skills, skillsLoading, plugins, pluginsLoading],
9075
() => {
91-
if (view.value === 'tools' && !toolsLoading.value && tools.value.length === 0) view.value = 'root';
9276
if (view.value === 'skills' && !skillsLoading.value && skills.value.length === 0) view.value = 'root';
9377
if (view.value === 'plugins' && !pluginsLoading.value && plugins.value.length === 0) view.value = 'root';
9478
},
@@ -140,15 +124,6 @@ function queueWrite(field: CapabilityField, selection: Ref<string[]>, previous:
140124
return write;
141125
}
142126
143-
function setToolEnabled(name: string, enabled: boolean): Promise<void> {
144-
const previous = [...selectedTools.value];
145-
const next = new Set(previous);
146-
if (enabled) next.add(name);
147-
else next.delete(name);
148-
selectedTools.value = [...next];
149-
return queueWrite('tools', selectedTools, previous);
150-
}
151-
152127
function setMcpServerEnabled(id: string, enabled: boolean): Promise<void> {
153128
const previous = [...selectedMcpServers.value];
154129
const next = new Set(previous);
@@ -189,13 +164,6 @@ function setPluginEnabled(id: string, enabled: boolean): void {
189164
<div class="capability-viewport">
190165
<div class="capability-track" :class="{ 'is-drilled': view !== 'root' }">
191166
<div class="capability-view">
192-
<MenuRow v-if="showTools" :count="selectedTools.length" @click="view = 'tools'">
193-
<template #label>{{ t('capabilityMenu.tools.title') }}</template>
194-
<template #trailing>
195-
<svg class="chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 3 5 5-5 5" /></svg>
196-
</template>
197-
</MenuRow>
198-
199167
<MenuRow v-if="showSkills" :count="skills.length" @click="view = 'skills'">
200168
<template #label>{{ t('capabilityMenu.skills.title') }}</template>
201169
<template #trailing>
@@ -247,34 +215,7 @@ function setPluginEnabled(id: string, enabled: boolean): void {
247215
<template #label>{{ drilldownTitle || t('capabilityMenu.back') }}</template>
248216
</MenuRow>
249217

250-
<template v-if="view === 'tools'">
251-
<p class="capability-caption">{{ t('capabilityMenu.tools.caption') }}</p>
252-
<div v-if="toolsLoading" class="capability-loading">
253-
<ActivitySpinner :label="t('capabilityMenu.loading')" />
254-
</div>
255-
<template v-else>
256-
<MenuRow
257-
v-for="tool in tools"
258-
:key="tool.name"
259-
class="capability-row"
260-
:selected="selectedTools.includes(tool.name)"
261-
:title="tool.description"
262-
@click="void setToolEnabled(tool.name, !selectedTools.includes(tool.name))"
263-
>
264-
<template #label>{{ tool.name }}</template>
265-
<template #trailing>
266-
<SwitchToggle
267-
:model-value="selectedTools.includes(tool.name)"
268-
:aria-label="t('capabilityMenu.tools.toggle', { name: tool.name })"
269-
@click.stop
270-
@update:model-value="void setToolEnabled(tool.name, $event)"
271-
/>
272-
</template>
273-
</MenuRow>
274-
</template>
275-
</template>
276-
277-
<template v-else-if="view === 'skills'">
218+
<template v-if="view === 'skills'">
278219
<p class="capability-caption">{{ t('capabilityMenu.skills.caption') }}</p>
279220
<div v-if="skillsLoading" class="capability-loading">
280221
<ActivitySpinner :label="t('capabilityMenu.loading')" />

apps/pythinker-web/src/components/settings/SettingsPane.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
AppSession,
88
AppSkill,
99
AppSubagent,
10+
AppTool,
1011
} from '../../api/types';
1112
import type { ColorScheme, Theme } from '../../composables/usePythinkerWebClient';
1213
import type { SettingsTab } from '../../composables/useSettingsNav';
@@ -20,6 +21,7 @@ import HooksPage from './pages/HooksPage.vue';
2021
import PluginsPage from './pages/PluginsPage.vue';
2122
import SkillsPage from './pages/SkillsPage.vue';
2223
import SubagentsPage from './pages/SubagentsPage.vue';
24+
import ToolsPage from './pages/ToolsPage.vue';
2325
import UsagePage from './pages/UsagePage.vue';
2426
2527
defineProps<{
@@ -41,6 +43,10 @@ defineProps<{
4143
sessions?: AppSession[];
4244
plugins?: AppPlugin[];
4345
subagents?: AppSubagent[];
46+
tools?: AppTool[];
47+
toolsLoading?: boolean;
48+
enabledTools?: string[];
49+
sessionId?: string;
4450
}>();
4551
4652
const emit = defineEmits<{
@@ -54,6 +60,7 @@ const emit = defineEmits<{
5460
updateConfig: [patch: Partial<AppConfig>];
5561
restartConnector: [connectorId: string];
5662
setPluginEnabled: [payload: { pluginId: string; enabled: boolean }];
63+
setTools: [names: string[]];
5764
close: [];
5865
}>();
5966
@@ -94,6 +101,14 @@ const { t } = useI18n();
94101
:config-saving="configSaving"
95102
@update-config="emit('updateConfig', $event)"
96103
/>
104+
<ToolsPage
105+
v-show="activeTab === 'tools'"
106+
:tools="tools"
107+
:tools-loading="toolsLoading"
108+
:enabled-tools="enabledTools"
109+
:session-id="sessionId"
110+
@set-tools="emit('setTools', $event)"
111+
/>
97112
<PluginsPage
98113
v-show="activeTab === 'plugins'"
99114
:plugins="plugins"
@@ -150,7 +165,7 @@ const { t } = useI18n();
150165
cursor: pointer;
151166
}
152167
.close-btn:hover {
153-
color: #fff;
168+
color: var(--bg);
154169
background: var(--err);
155170
border-color: var(--err);
156171
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue';
3+
import { useI18n } from 'vue-i18n';
4+
import type { AppTool } from '../../../api/types';
5+
import ListingRow from '../ListingRow.vue';
6+
7+
const props = defineProps<{
8+
tools?: AppTool[];
9+
toolsLoading?: boolean;
10+
enabledTools?: string[];
11+
sessionId?: string;
12+
}>();
13+
14+
const emit = defineEmits<{
15+
setTools: [names: string[]];
16+
}>();
17+
18+
const { t } = useI18n();
19+
const toolList = computed(() => props.tools ?? []);
20+
const enabledToolNames = computed(() => props.enabledTools ?? toolList.value.map((tool) => tool.name));
21+
22+
function isToolEnabled(name: string): boolean {
23+
return enabledToolNames.value.includes(name);
24+
}
25+
26+
function setToolEnabled(name: string, enabled: boolean): void {
27+
const next = new Set(enabledToolNames.value);
28+
if (enabled) next.add(name);
29+
else next.delete(name);
30+
emit('setTools', [...next]);
31+
}
32+
33+
function enableAll(): void {
34+
emit('setTools', toolList.value.map((tool) => tool.name));
35+
}
36+
</script>
37+
38+
<template>
39+
<section id="settings-panel-tools" class="panel" role="tabpanel" aria-labelledby="settings-tab-tools">
40+
<section class="sec">
41+
<h2 class="page-title">{{ t('settings.tools.title') }}</h2>
42+
<p class="sec-note">{{ t('settings.tools.note') }}</p>
43+
<p v-if="!props.sessionId" class="sec-empty">{{ t('settings.tools.noSession') }}</p>
44+
<p v-else-if="props.toolsLoading" class="sec-empty">{{ t('settings.tools.loading') }}</p>
45+
<p v-else-if="toolList.length === 0" class="sec-empty">{{ t('settings.tools.empty') }}</p>
46+
<template v-else>
47+
<div class="listing">
48+
<ListingRow v-for="tool in toolList" :key="tool.name" :name="tool.name" mono :off="!isToolEnabled(tool.name)">
49+
<template #glyph>
50+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M8 4h8M6 8h12v11H6zM9 12h6M9 15h4" stroke-linecap="round" stroke-linejoin="round" /></svg>
51+
</template>
52+
<template #actions>
53+
<button type="button" class="switch sm" role="switch" :class="{ on: isToolEnabled(tool.name) }" :aria-checked="isToolEnabled(tool.name)" :aria-label="t('settings.tools.toggleAria', { name: tool.name })" @click="setToolEnabled(tool.name, !isToolEnabled(tool.name))"><span class="knob" /></button>
54+
</template>
55+
</ListingRow>
56+
</div>
57+
<div class="actions">
58+
<button type="button" class="act" @click="enableAll">{{ t('settings.tools.enableAll') }}</button>
59+
</div>
60+
</template>
61+
</section>
62+
</section>
63+
</template>
64+
65+
<style scoped src="../settings.css"></style>

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { shallowRef, toValue, type MaybeRefOrGetter } from 'vue';
33
export type SettingsTab =
44
| 'general'
55
| 'agent'
6+
| 'tools'
67
| 'skills'
78
| 'connectors'
89
| 'plugins'
@@ -26,6 +27,7 @@ export const tabGroups: Array<{
2627
{
2728
titleKey: 'settings.groups.capabilities',
2829
tabs: [
30+
{ id: 'tools', labelKey: 'settings.tabs.tools' },
2931
{ id: 'plugins', labelKey: 'settings.tabs.plugins' },
3032
{ id: 'skills', labelKey: 'settings.tabs.skills' },
3133
{ id: 'subagents', labelKey: 'settings.tabs.subagents' },
@@ -51,18 +53,20 @@ type UseSettingsNavOptions = {
5153
};
5254
onLoadConnectors: () => void;
5355
onLoadPlugins: () => void;
56+
onLoadTools: () => void;
5457
onLoadSubagents: () => void;
5558
};
5659

5760
export function useSettingsNav(options: UseSettingsNavOptions) {
5861
const activeTab = shallowRef<SettingsTab>('general');
5962

6063
function loadFor(tab: SettingsTab): void {
64+
if (tab === 'tools') options.onLoadTools();
6165
if (tab === 'connectors' && toValue(options.counts.connectors) === 0) options.onLoadConnectors();
6266
if (tab === 'plugins' && toValue(options.counts.plugins) === 0) options.onLoadPlugins();
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.
67+
// Connectors and plugins are daemon-wide, so one load holds. Tools and
68+
// subagents are session-scoped, so always refetch them for the active
69+
// session.
6670
if (tab === 'subagents') options.onLoadSubagents();
6771
}
6872

apps/pythinker-web/src/i18n/locales/en/settings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export default {
1212
tabs: {
1313
general: 'General',
1414
agent: 'Agent',
15+
tools: 'Tools',
1516
plugins: 'Plugins',
1617
skills: 'Skills',
1718
subagents: 'Subagents',
@@ -21,6 +22,15 @@ export default {
2122
advanced: 'Advanced',
2223
experimental: 'Experimental',
2324
},
25+
tools: {
26+
title: 'Tools',
27+
note: 'Applies to this session immediately. The selection is saved for the current session only.',
28+
noSession: 'No active session.',
29+
loading: 'Loading tools…',
30+
empty: 'No tools are available.',
31+
enableAll: 'Enable all tools',
32+
toggleAria: 'Enable {name}',
33+
},
2434
skills: {
2535
title: 'Skills',
2636
note: 'Skills the agent can use, grouped by source. A skill you turn off is never loaded.',

0 commit comments

Comments
 (0)