Skip to content

Commit ca7daaf

Browse files
committed
fix(web): keep the connectors menu inside the toolbar and the viewport
1 parent 865b4e6 commit ca7daaf

6 files changed

Lines changed: 71 additions & 57 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": patch
3+
---
4+
5+
Clean up the web composer capability control: the selected tools no longer render as chips in the toolbar, the button reads "Connectors", and the menu panel stays inside the window when its content loads.

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

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import { computed, ref, watch, type Ref } from 'vue';
33
import { useI18n } from 'vue-i18n';
44
import ActivitySpinner from './ActivitySpinner.vue';
5-
import Chip from './ui/Chip.vue';
65
import MenuRow from './ui/MenuRow.vue';
76
import Popover from './ui/Popover.vue';
87
import SwitchToggle from './ui/SwitchToggle.vue';
@@ -48,12 +47,6 @@ const capabilities = computed<SessionCapabilities>(() =>
4847
: {},
4948
);
5049
51-
const selectedToolItems = computed(() =>
52-
tools.value.filter((tool) => selectedTools.value.includes(tool.name)),
53-
);
54-
const selectedMcpItems = computed(() =>
55-
connectors.value.filter((server) => selectedMcpServers.value.includes(server.id)),
56-
);
5750
const showTools = computed(() => toolsLoading.value || tools.value.length > 0);
5851
const showSkills = computed(() => skillsLoading.value || skills.value.length > 0);
5952
const showMcp = computed(() => connectorsLoading.value || connectors.value.length > 0);
@@ -191,29 +184,6 @@ function setPluginEnabled(id: string, enabled: boolean): void {
191184
<span class="capability-trigger-label">{{ t('capabilityMenu.trigger') }}</span>
192185
</button>
193186

194-
<div v-if="selectedToolItems.length > 0 || selectedMcpItems.length > 0" class="capability-chip-strip">
195-
<Chip
196-
v-for="tool in selectedToolItems"
197-
:key="`tool:${tool.name}`"
198-
class="capability-chip"
199-
variant="active"
200-
:label="tool.name"
201-
:title="t('capabilityMenu.tools.toggle', { name: tool.name })"
202-
:aria-label="t('capabilityMenu.tools.toggle', { name: tool.name })"
203-
@click="void setToolEnabled(tool.name, false)"
204-
/>
205-
<Chip
206-
v-for="server in selectedMcpItems"
207-
:key="`mcp:${server.id}`"
208-
class="capability-chip"
209-
variant="active"
210-
:label="server.name"
211-
:title="t('capabilityMenu.mcp.toggle', { name: server.name })"
212-
:aria-label="t('capabilityMenu.mcp.toggle', { name: server.name })"
213-
@click="void setMcpServerEnabled(server.id, false)"
214-
/>
215-
</div>
216-
217187
<Popover :anchor="triggerRef" :open="open" :label="t('capabilityMenu.triggerLabel')" @close="close">
218188
<div class="capability-panel">
219189
<div class="capability-viewport">
@@ -361,9 +331,8 @@ function setPluginEnabled(id: string, enabled: boolean): void {
361331
.capability-control {
362332
display: flex;
363333
align-items: center;
364-
gap: 4px;
334+
flex: none;
365335
min-width: 0;
366-
flex: 1 1 auto;
367336
}
368337
369338
.capability-trigger {
@@ -397,26 +366,6 @@ function setPluginEnabled(id: string, enabled: boolean): void {
397366
flex: none;
398367
}
399368
400-
.capability-chip-strip {
401-
display: flex;
402-
align-items: center;
403-
gap: 4px;
404-
min-width: 0;
405-
overflow-x: auto;
406-
scrollbar-width: none;
407-
}
408-
409-
.capability-chip-strip::-webkit-scrollbar {
410-
display: none;
411-
}
412-
413-
.capability-chip {
414-
flex: none;
415-
min-width: 0;
416-
padding: 5px 6px;
417-
font-size: var(--ui-font-size-xs);
418-
}
419-
420369
.capability-panel {
421370
width: 280px;
422371
max-height: 288px;

apps/pythinker-web/src/components/ui/Popover.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const panelRef = ref<HTMLElement | null>(null);
2323
const panelStyle = ref<Record<string, string>>({});
2424
let opener: HTMLElement | null = null;
2525
let listenersAttached = false;
26+
let panelResizeObserver: ResizeObserver | null = null;
2627
2728
function positionPanel(): void {
2829
const anchor = props.anchor;
@@ -74,6 +75,13 @@ function attachListeners(): void {
7475
document.addEventListener('keydown', onKeydown);
7576
document.addEventListener('scroll', onViewportChange, true);
7677
window.addEventListener('resize', onViewportChange);
78+
// The panel is measured once on open, but its content arrives later: a menu
79+
// that opens on a spinner and then fills with rows grows downward past the
80+
// viewport, because only `top` was written. Re-place it whenever it resizes.
81+
if (typeof ResizeObserver !== 'undefined' && panelRef.value) {
82+
panelResizeObserver = new ResizeObserver(onViewportChange);
83+
panelResizeObserver.observe(panelRef.value);
84+
}
7785
listenersAttached = true;
7886
}
7987
@@ -83,6 +91,8 @@ function detachListeners(): void {
8391
document.removeEventListener('keydown', onKeydown);
8492
document.removeEventListener('scroll', onViewportChange, true);
8593
window.removeEventListener('resize', onViewportChange);
94+
panelResizeObserver?.disconnect();
95+
panelResizeObserver = null;
8696
listenersAttached = false;
8797
}
8898

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export default {
2-
trigger: 'Capabilities',
3-
triggerLabel: 'Choose which capabilities this session may use',
2+
trigger: 'Connectors',
3+
triggerLabel: 'Choose which connectors this session may use',
44
back: 'Back',
55
loading: 'Loading…',
66
tools: {

apps/pythinker-web/test/capability-menu.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,18 @@ describe('CapabilityMenu', () => {
360360
expect(checked).toEqual([['Docs', 'false'], ['Issue tracker', 'true']]);
361361
});
362362

363-
it('renders selected tools and MCP servers as chips', () => {
363+
it('renders the selection only inside the panel, never as toolbar chips', async () => {
364+
// A chip per selected tool overflowed the composer toolbar: with every tool
365+
// on by default the strip pushed the permission and mode controls off the
366+
// row and clipped the last chip mid-word. The panel already carries counts.
364367
const wrapper = mountMenu();
368+
expect(wrapper.findAll('.chip')).toHaveLength(0);
365369

366-
expect(wrapper.findAll('.chip').map((chip) => chip.text())).toEqual(['×Read', '×Docs']);
370+
await wrapper.get('.capability-trigger').trigger('click');
371+
await flushPromises();
372+
373+
expect(wrapper.findAll('.chip')).toHaveLength(0);
374+
expect(document.body.querySelectorAll('.mcp-row').length).toBeGreaterThan(0);
367375
});
368376

369377
it('keeps CapabilityMenu.vue free of dark utilities and color literals', () => {

apps/pythinker-web/test/ui-primitives.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
22
import { join } from 'node:path';
33
import { mount } from '@vue/test-utils';
44
import { nextTick } from 'vue';
5-
import { afterEach, describe, expect, it } from 'vitest';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
66

77
import Chip from '../src/components/ui/Chip.vue';
88
import MenuRow from '../src/components/ui/MenuRow.vue';
@@ -27,6 +27,7 @@ async function settle(): Promise<void> {
2727

2828
afterEach(() => {
2929
document.body.replaceChildren();
30+
vi.unstubAllGlobals();
3031
});
3132

3233
describe('MenuRow', () => {
@@ -149,6 +150,47 @@ describe('Popover', () => {
149150
wrapper.unmount();
150151
});
151152

153+
it('re-places the panel when its content grows after opening', async () => {
154+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 800 });
155+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
156+
const observed: (() => void)[] = [];
157+
vi.stubGlobal('ResizeObserver', class {
158+
constructor(callback: () => void) {
159+
observed.push(callback);
160+
}
161+
observe(): void {}
162+
disconnect(): void {}
163+
});
164+
const anchor = document.createElement('button');
165+
anchor.getBoundingClientRect = () => ({
166+
bottom: 200, height: 20, left: 100, right: 160, top: 180,
167+
width: 60, x: 100, y: 180, toJSON: () => ({}),
168+
});
169+
document.body.append(anchor);
170+
const wrapper = mount(Popover, {
171+
attachTo: document.body,
172+
props: { anchor, open: true },
173+
slots: { default: 'Menu' },
174+
});
175+
const panel = document.body.querySelector('[role="dialog"]') as HTMLElement;
176+
Object.defineProperty(panel, 'offsetWidth', { configurable: true, value: 100 });
177+
Object.defineProperty(panel, 'offsetHeight', { configurable: true, writable: true, value: 80 });
178+
179+
await settle();
180+
expect(panel.style.top).toBe('204px');
181+
182+
// The menu opens on a spinner and fills in afterwards. Without the resize
183+
// observer the panel keeps the `top` it was measured at and its new bottom
184+
// runs off the viewport.
185+
Object.defineProperty(panel, 'offsetHeight', { configurable: true, value: 700 });
186+
expect(observed).toHaveLength(1);
187+
observed[0]!();
188+
await settle();
189+
190+
expect(panel.style.top).toBe('16px');
191+
wrapper.unmount();
192+
});
193+
152194
it('closes on Escape', async () => {
153195
const anchor = document.createElement('button');
154196
document.body.append(anchor);

0 commit comments

Comments
 (0)