Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-connectors-form-and-dialog-corners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Lay the MCP server form out in even rows instead of a ragged grid, and stop the provider manager header and footer from squaring off the dialog corners.
4 changes: 3 additions & 1 deletion apps/pythinker-web/src/components/ProviderManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ function statusLabel(status: AppProvider['status']): string {
max-height: calc(100vh - 80px);
display: flex;
flex-direction: column;
/* The header, the footer, and the list scrollbar all paint to the dialog
edge, so they square off the rounded corners without this. */
overflow: hidden;
font-family: var(--mono);
box-shadow: 0 8px 32px rgba(0,0,0,0.14);
}
Expand Down Expand Up @@ -578,7 +581,6 @@ function statusLabel(status: AppProvider['status']): string {
color: var(--faint);
border-top: 1px solid var(--line2);
background: var(--panel);
border-radius: 0 0 4px 4px;
}

@media (max-width: 640px) {
Expand Down
20 changes: 14 additions & 6 deletions apps/pythinker-web/src/components/settings/McpServerForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,23 +99,23 @@ function submit(): void {
<option value="sse">sse</option>
</select>
</label>
<label v-if="form.transport === 'stdio'" class="connector-field">
<label v-if="form.transport === 'stdio'" class="connector-field connector-field-wide">
<span class="rlabel">{{ t('settings.connectors.form.command') }}</span>
<input v-model="form.command" class="page-search" required />
</label>
<label v-if="form.transport === 'stdio'" class="connector-field">
<label v-if="form.transport === 'stdio'" class="connector-field connector-field-wide">
<span class="rlabel">{{ t('settings.connectors.form.args') }}</span>
<textarea v-model="form.args" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.argsHint')" />
</label>
<label v-if="form.transport === 'stdio'" class="connector-field">
<label v-if="form.transport === 'stdio'" class="connector-field connector-field-wide">
<span class="rlabel">{{ t('settings.connectors.form.env') }}</span>
<textarea v-model="form.env" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.objectHint')" />
</label>
<label v-else class="connector-field">
<label v-else class="connector-field connector-field-wide">
<span class="rlabel">{{ t('settings.connectors.form.url') }}</span>
<input v-model="form.url" class="page-search" type="url" required />
</label>
<label v-if="form.transport !== 'stdio'" class="connector-field">
<label v-if="form.transport !== 'stdio'" class="connector-field connector-field-wide">
<span class="rlabel">{{ t('settings.connectors.form.headers') }}</span>
<textarea v-model="form.headers" class="page-search connector-textarea" :placeholder="t('settings.connectors.form.objectHint')" />
</label>
Expand All @@ -139,11 +139,16 @@ function submit(): void {
background: var(--panel);
}
.connector-fields {
/* Two fixed columns, not auto-fit: a pane-wide grid stretches the short
fields and leaves a ragged tail row. Name and Transport share the first
row; every longer field spans both columns so no row ends half empty. */
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
max-width: 640px;
}
.connector-field { min-width: 0; }
.connector-field-wide { grid-column: 1 / -1; }
.connector-field .page-search { margin: 4px 0 0; }
.connector-textarea {
min-height: 64px;
Expand All @@ -155,4 +160,7 @@ function submit(): void {
gap: 8px;
margin-top: 4px;
}
@media (max-width: 640px) {
.connector-fields { grid-template-columns: 1fr; }
}
</style>
28 changes: 28 additions & 0 deletions apps/pythinker-web/test/connectors-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,34 @@ describe('ConnectorsPage', () => {
expect(wrapper.findAll('.connector-remove')).toHaveLength(1);
});

it('spans every long field across both form columns', async () => {
const wrapper = mountPage();

await wrapper.get('button.act').trigger('click');
const labelOf = (field: ReturnType<typeof wrapper.get>): string => field.get('.rlabel').text();
const layout = (): { narrow: string[]; wide: string[] } => ({
narrow: wrapper
.findAll('.connector-field')
.filter((field) => !field.classes().includes('connector-field-wide'))
.map(labelOf),
wide: wrapper.findAll('.connector-field-wide').map(labelOf),
});

// Name and Transport pair up on the first row; the rest take a full row,
// so no row is left half empty.
expect(layout()).toEqual({
narrow: ['Name', 'Transport'],
wide: ['Command', 'Arguments', 'Environment (JSON)'],
});

await wrapper.get('select').setValue('http');

expect(layout()).toEqual({
narrow: ['Name', 'Transport'],
wide: ['URL', 'Headers (JSON)'],
});
});
Comment thread
elkaix marked this conversation as resolved.

it('shows the daemon validation message after a rejected write', () => {
const wrapper = mountPage({ connectorsError: 'MCP server id must be trimmed' });

Expand Down
32 changes: 32 additions & 0 deletions apps/pythinker-web/test/provider-manager-chrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

import { describe, expect, it } from 'vitest';

// The header, the footer, and the provider list all paint their own background
// to the dialog edge, so a rounded dialog needs to clip them. jsdom applies no
// scoped-SFC styles, so the rule itself is the contract under test.
const source = readFileSync(
resolve(import.meta.dirname, '../src/components/ProviderManager.vue'),
'utf8',
);

function blockOf(selector: string): string {
const start = source.indexOf(`\n${selector} {`);
expect(start).toBeGreaterThan(-1);
const end = source.indexOf('\n}', start);
return source.slice(start, end);
}

describe('ProviderManager dialog chrome', () => {
it('clips its children to the rounded corners', () => {
const dialog = blockOf('.dialog');

expect(dialog).toMatch(/border-radius:\s*4px/u);
expect(dialog).toMatch(/overflow:\s*hidden/u);
});

it('leaves the corner rounding to the dialog instead of the footer', () => {
expect(blockOf('.footer-hint')).not.toMatch(/border-radius/u);
});
});
Loading