Skip to content

Commit e9a4ef4

Browse files
committed
feat(web): adopt the open-webui row design across the settings pages
Port the connector-row anatomy from the open-webui reference: flat borderless rows with a leading glyph, the name and its tags inline, and a right-hand action cluster. Only the row content fades when an entry is off, so the switch that turns it back on keeps full contrast.
1 parent 07e9cfc commit e9a4ef4

2 files changed

Lines changed: 158 additions & 74 deletions

File tree

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

Lines changed: 156 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -729,21 +729,28 @@ function setTab(tab: SettingsTab): void {
729729
class="listing-row"
730730
:class="{ off: !isSkillEnabled(skill.name) }"
731731
>
732-
<div class="listing-main">
733-
<span class="listing-name mono">{{ skill.name }}</span>
734-
<span v-if="skill.disableModelInvocation" class="tag">{{ t('settings.skills.slashOnly') }}</span>
735-
<span class="listing-desc">{{ skill.description }}</span>
736-
<button
737-
type="button"
738-
class="switch sm"
739-
role="switch"
740-
:class="{ on: isSkillEnabled(skill.name) }"
741-
:aria-checked="isSkillEnabled(skill.name)"
742-
:aria-label="t('settings.skills.toggleAria', { name: skill.name })"
743-
@click="toggleSkill(skill.name)"
744-
>
745-
<span class="knob" />
746-
</button>
732+
<div class="listing-top">
733+
<div class="listing-main">
734+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
735+
<path d="M12 3l2.1 4.9 5.4.5-4.1 3.6 1.2 5.3L12 14.6 7.4 17.3l1.2-5.3L4.5 8.4l5.4-.5L12 3z" stroke-linejoin="round" />
736+
</svg>
737+
<span class="listing-name mono">{{ skill.name }}</span>
738+
<span v-if="skill.disableModelInvocation" class="tag">{{ t('settings.skills.slashOnly') }}</span>
739+
<span class="listing-desc">{{ skill.description }}</span>
740+
</div>
741+
<div class="row-actions">
742+
<button
743+
type="button"
744+
class="switch sm"
745+
role="switch"
746+
:class="{ on: isSkillEnabled(skill.name) }"
747+
:aria-checked="isSkillEnabled(skill.name)"
748+
:aria-label="t('settings.skills.toggleAria', { name: skill.name })"
749+
@click="toggleSkill(skill.name)"
750+
>
751+
<span class="knob" />
752+
</button>
753+
</div>
747754
</div>
748755
</div>
749756
</div>
@@ -765,16 +772,31 @@ function setTab(tab: SettingsTab): void {
765772
<p v-else-if="(connectors?.length ?? 0) === 0" class="sec-empty">{{ t('settings.connectors.empty') }}</p>
766773
<div v-else class="listing">
767774
<div v-for="connector in connectors" :key="connector.id" class="listing-row">
768-
<div class="listing-main">
769-
<span class="dot" :class="`s-${connector.status}`" aria-hidden="true" />
770-
<span class="listing-name">{{ connector.name }}</span>
771-
<span class="tag">{{ connector.transport }}</span>
772-
<span class="listing-meta">{{ t('settings.connectors.tools', { count: connector.toolCount }) }}</span>
773-
<button type="button" class="act" @click="emit('restartConnector', connector.id)">
774-
{{ t('settings.connectors.restart') }}
775-
</button>
775+
<div class="listing-top">
776+
<div class="listing-main">
777+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
778+
<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 0 1-12 0V8zM12 17v4" stroke-linecap="round" />
779+
</svg>
780+
<span class="listing-name">{{ connector.name }}</span>
781+
<span class="tag">{{ connector.transport }}</span>
782+
<span class="listing-desc">{{ t(`settings.connectors.status.${connector.status}`) }}</span>
783+
<span class="listing-meta">{{ t('settings.connectors.tools', { count: connector.toolCount }) }}</span>
784+
</div>
785+
<div class="row-actions">
786+
<span class="dot" :class="`s-${connector.status}`" aria-hidden="true" />
787+
<button
788+
type="button"
789+
class="icon-btn"
790+
:title="t('settings.connectors.restart')"
791+
:aria-label="t('settings.connectors.restart')"
792+
@click="emit('restartConnector', connector.id)"
793+
>
794+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
795+
<path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5" stroke-linecap="round" stroke-linejoin="round" />
796+
</svg>
797+
</button>
798+
</div>
776799
</div>
777-
<p class="listing-desc">{{ t(`settings.connectors.status.${connector.status}`) }}</p>
778800
<p v-if="connector.lastError" class="listing-error">{{ connector.lastError }}</p>
779801
</div>
780802
</div>
@@ -800,22 +822,29 @@ function setTab(tab: SettingsTab): void {
800822
class="listing-row"
801823
:class="{ off: !plugin.enabled }"
802824
>
803-
<div class="listing-main">
804-
<span class="listing-name">{{ plugin.displayName }}</span>
805-
<span v-if="plugin.version" class="tag">{{ plugin.version }}</span>
806-
<span class="tag">{{ plugin.source }}</span>
807-
<span class="listing-meta">{{ t('settings.plugins.counts', { skills: plugin.skillCount, servers: plugin.mcpServerCount }) }}</span>
808-
<button
809-
type="button"
810-
class="switch sm"
811-
role="switch"
812-
:class="{ on: plugin.enabled }"
813-
:aria-checked="plugin.enabled"
814-
:aria-label="t('settings.plugins.toggleAria', { name: plugin.displayName })"
815-
@click="emit('setPluginEnabled', { pluginId: plugin.id, enabled: !plugin.enabled })"
816-
>
817-
<span class="knob" />
818-
</button>
825+
<div class="listing-top">
826+
<div class="listing-main">
827+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
828+
<path d="M10 4a2 2 0 1 1 4 0v1h3a1 1 0 0 1 1 1v3h1a2 2 0 1 1 0 4h-1v3a1 1 0 0 1-1 1h-3v-1a2 2 0 1 0-4 0v1H6a1 1 0 0 1-1-1v-3H4a2 2 0 1 1 0-4h1V6a1 1 0 0 1 1-1h4V4z" stroke-linejoin="round" />
829+
</svg>
830+
<span class="listing-name">{{ plugin.displayName }}</span>
831+
<span v-if="plugin.version" class="tag">{{ plugin.version }}</span>
832+
<span class="tag">{{ plugin.source }}</span>
833+
<span class="listing-meta">{{ t('settings.plugins.counts', { skills: plugin.skillCount, servers: plugin.mcpServerCount }) }}</span>
834+
</div>
835+
<div class="row-actions">
836+
<button
837+
type="button"
838+
class="switch sm"
839+
role="switch"
840+
:class="{ on: plugin.enabled }"
841+
:aria-checked="plugin.enabled"
842+
:aria-label="t('settings.plugins.toggleAria', { name: plugin.displayName })"
843+
@click="emit('setPluginEnabled', { pluginId: plugin.id, enabled: !plugin.enabled })"
844+
>
845+
<span class="knob" />
846+
</button>
847+
</div>
819848
</div>
820849
<p v-if="plugin.hasErrors" class="listing-error">{{ t('settings.plugins.hasErrors') }}</p>
821850
</div>
@@ -837,14 +866,20 @@ function setTab(tab: SettingsTab): void {
837866
<p v-if="(subagents?.length ?? 0) === 0" class="sec-empty">{{ t('settings.subagents.empty') }}</p>
838867
<div v-else class="listing">
839868
<div v-for="agent in subagents" :key="agent.name" class="listing-row">
840-
<div class="listing-main">
841-
<span class="listing-name mono">{{ agent.name }}</span>
842-
<span class="tag">{{ agent.source }}</span>
843-
<span class="tag">{{ t('settings.subagents.tools', { count: agent.tools.length }) }}</span>
844-
<span v-if="agent.model" class="listing-meta">{{ agent.model }}</span>
845-
<span v-if="agent.effort" class="tag">{{ agent.effort }}</span>
869+
<div class="listing-top">
870+
<div class="listing-main">
871+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
872+
<rect x="4" y="8" width="16" height="11" rx="3" />
873+
<path d="M12 4v4M9 13h.01M15 13h.01" stroke-linecap="round" />
874+
</svg>
875+
<span class="listing-name mono">{{ agent.name }}</span>
876+
<span class="tag">{{ agent.source }}</span>
877+
<span class="tag">{{ t('settings.subagents.tools', { count: agent.tools.length }) }}</span>
878+
<span v-if="agent.effort" class="tag">{{ agent.effort }}</span>
879+
<span v-if="agent.model" class="listing-meta">{{ agent.model }}</span>
880+
</div>
846881
</div>
847-
<p v-if="agent.description" class="listing-desc">{{ agent.description }}</p>
882+
<p v-if="agent.description" class="listing-desc listing-indent">{{ agent.description }}</p>
848883
</div>
849884
</div>
850885
</section>
@@ -871,11 +906,16 @@ function setTab(tab: SettingsTab): void {
871906
:key="`${group.event}/${index}`"
872907
class="listing-row"
873908
>
874-
<div class="listing-main">
875-
<span class="listing-name mono">{{ hook.matcher ?? '*' }}</span>
876-
<span class="tag">{{ hook.type ?? 'command' }}</span>
877-
<span v-if="hook.async === true" class="tag">{{ t('settings.hooks.async') }}</span>
878-
<span v-if="hook.timeout !== undefined" class="listing-meta">{{ t('settings.hooks.timeout', { seconds: hook.timeout }) }}</span>
909+
<div class="listing-top">
910+
<div class="listing-main">
911+
<svg class="listing-glyph" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
912+
<path d="M13 3L5 14h6l-2 7 8-11h-6l2-7z" stroke-linejoin="round" />
913+
</svg>
914+
<span class="listing-name mono">{{ hook.matcher ?? '*' }}</span>
915+
<span class="tag">{{ hook.type ?? 'command' }}</span>
916+
<span v-if="hook.async === true" class="tag">{{ t('settings.hooks.async') }}</span>
917+
<span v-if="hook.timeout !== undefined" class="listing-meta">{{ t('settings.hooks.timeout', { seconds: hook.timeout }) }}</span>
918+
</div>
879919
</div>
880920
<p class="listing-path mono">{{ hook.command ?? hook.url ?? '—' }}</p>
881921
</div>
@@ -917,9 +957,11 @@ function setTab(tab: SettingsTab): void {
917957
<p v-if="usageByModel.length === 0" class="sec-empty">{{ t('settings.usage.empty') }}</p>
918958
<div v-else class="listing">
919959
<div v-for="entry in usageByModel" :key="entry.model" class="listing-row">
920-
<div class="listing-main">
921-
<span class="listing-name">{{ entry.model }}</span>
922-
<span class="listing-meta">{{ entry.share }}</span>
960+
<div class="listing-top">
961+
<div class="listing-main">
962+
<span class="listing-name">{{ entry.model }}</span>
963+
<span class="listing-meta">{{ entry.share }}</span>
964+
</div>
923965
</div>
924966
<div class="usage-bar"><span :style="{ width: entry.share }" /></div>
925967
</div>
@@ -1353,69 +1395,110 @@ function setTab(tab: SettingsTab): void {
13531395
.listing {
13541396
display: flex;
13551397
flex-direction: column;
1356-
gap: 6px;
1398+
gap: 2px;
13571399
}
13581400
.listing-head {
13591401
margin: 14px 0 6px;
13601402
font-size: calc(var(--ui-font-size) - 2px);
13611403
font-weight: 600;
13621404
color: var(--muted);
13631405
}
1406+
/* Rows are flat rather than carded: the leading glyph, the name and the right
1407+
action cluster carry the structure, so a border would only add noise. */
13641408
.listing-row {
1365-
padding: 8px 12px;
1366-
border: 1px solid var(--line);
1367-
border-radius: 10px;
1368-
background: var(--panel);
1409+
display: flex;
1410+
flex-direction: column;
1411+
padding: 5px 0;
13691412
}
1370-
/* A disabled skill stays readable but clearly recedes. */
1371-
.listing-row.off .listing-name,
1372-
.listing-row.off .listing-desc {
1373-
color: var(--faint);
1413+
.listing-top {
1414+
display: flex;
1415+
align-items: center;
1416+
gap: 12px;
1417+
min-width: 0;
13741418
}
1419+
/* Only the content fades when an entry is off — the switch that turns it back
1420+
on has to stay at full contrast. */
1421+
.listing-row.off .listing-main { opacity: 0.5; }
13751422
.listing-main {
13761423
display: flex;
13771424
align-items: center;
13781425
gap: 8px;
1426+
flex: 1;
13791427
min-width: 0;
13801428
}
1429+
.row-actions {
1430+
display: flex;
1431+
flex: none;
1432+
align-items: center;
1433+
gap: 4px;
1434+
}
1435+
.listing-glyph {
1436+
flex: none;
1437+
width: 15px;
1438+
height: 15px;
1439+
color: var(--faint);
1440+
}
13811441
.listing-name {
1382-
font-weight: 600;
1442+
flex: none;
1443+
font-weight: 500;
13831444
color: var(--ink);
13841445
}
13851446
.listing-meta {
1386-
margin-left: auto;
1447+
flex: none;
13871448
font-size: calc(var(--ui-font-size) - 2px);
1388-
color: var(--muted);
1449+
color: var(--faint);
13891450
}
13901451
.listing-desc,
13911452
.listing-path,
13921453
.listing-error {
1393-
margin: 3px 0 0;
1454+
margin: 2px 0 0;
13941455
font-size: calc(var(--ui-font-size) - 2px);
13951456
color: var(--muted);
13961457
}
1397-
/* On the skills page the description shares the row with the name, so it
1398-
truncates instead of wrapping the switch must stay on the same line. */
1458+
/* Inside a row the description shares the line with the name, so it truncates
1459+
instead of wrapping and pushes the meta text to the right edge. */
13991460
.listing-main .listing-desc {
1400-
margin: 0 0 0 auto;
1401-
padding-left: 10px;
1461+
margin: 0;
14021462
min-width: 0;
14031463
overflow: hidden;
14041464
text-overflow: ellipsis;
14051465
white-space: nowrap;
1466+
color: var(--faint);
14061467
}
1468+
.listing-main .listing-meta { margin-left: auto; }
14071469
.listing-path {
1470+
padding-left: 27px;
14081471
color: var(--faint);
14091472
word-break: break-all;
14101473
}
1474+
.listing-error { padding-left: 27px; }
1475+
.listing-indent { padding-left: 27px; }
1476+
1477+
/* Ghost icon button for the per-row actions — no chrome until hover. */
1478+
.icon-btn {
1479+
display: flex;
1480+
flex: none;
1481+
align-items: center;
1482+
justify-content: center;
1483+
width: 24px;
1484+
height: 24px;
1485+
padding: 0;
1486+
border: none;
1487+
border-radius: 7px;
1488+
background: transparent;
1489+
color: var(--faint);
1490+
cursor: pointer;
1491+
}
1492+
.icon-btn svg { width: 15px; height: 15px; }
1493+
.icon-btn:hover { background: var(--soft); color: var(--ink); }
14111494
.listing-error {
14121495
color: var(--err);
14131496
}
14141497
.tag {
14151498
flex: none;
14161499
padding: 1px 6px;
14171500
border-radius: 5px;
1418-
border: 1px solid var(--line);
1501+
background: var(--soft);
14191502
font-size: calc(var(--ui-font-size) - 3px);
14201503
color: var(--muted);
14211504
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,8 @@ describe('SettingsDialog connectors page', () => {
331331
expect(panel.text()).toContain('spawn ENOENT');
332332
expect(panel.text()).toContain('2 tools');
333333

334-
await panel.findAll('.act')[1]!.trigger('click');
334+
// Restart is a ghost icon button in the row's action cluster.
335+
await panel.findAll('.icon-btn')[1]!.trigger('click');
335336
expect(wrapper.emitted('restartConnector')).toEqual([['mcp_2']]);
336337
});
337338
});

0 commit comments

Comments
 (0)