From 60fe153a59508098d2c8e71e83aca572ecb8c2bf Mon Sep 17 00:00:00 2001 From: Damon Date: Thu, 23 Jul 2026 22:36:30 -0400 Subject: [PATCH] feat(vault): collapse multi-protocol column to "primary (+N more)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-protocol team key renders one row PER protocol group, so spelling the whole comma-joined protocol list into every one of those rows both overflowed the 22% PROTOCOLS column and just re-stated the group header. The column now leads with the protocol of the group the row is rendered under and folds the remainder into a "(+N more)" hint. The full list stays reachable via the cell's title attribute and the detail drawer, so nothing is lost — only the redundancy. Leading with the ROW'S GROUP (not the key's first protocol) is the part that matters: otherwise the openai row of an anthropic+openai key labels itself "anthropic", which is the same class of bug 20260713 fixed for the row provider chip. The rule is extracted to a pure `protocolColumn()` helper so it is unit-testable without mounting the 7k-line vault page. Single-protocol and legacy (no-bindings) rows are byte-identical to before: extraCount is 0 and no hint renders. - New: pages/user/_shared/protocol-column.ts (+ 7 tests) - i18n: vault.protocolMore in en/zh Co-Authored-By: Claude Opus 4.8 --- .../user/_shared/protocol-column.test.ts | 56 +++++++++++++++++++ web/src/pages/user/_shared/protocol-column.ts | 33 +++++++++++ web/src/pages/user/vault/index.tsx | 23 ++++++-- web/src/shared/i18n/locales/en/common.json | 1 + web/src/shared/i18n/locales/zh/common.json | 1 + 5 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 web/src/pages/user/_shared/protocol-column.test.ts create mode 100644 web/src/pages/user/_shared/protocol-column.ts diff --git a/web/src/pages/user/_shared/protocol-column.test.ts b/web/src/pages/user/_shared/protocol-column.test.ts new file mode 100644 index 0000000..8b7e956 --- /dev/null +++ b/web/src/pages/user/_shared/protocol-column.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { protocolColumn } from './protocol-column'; + +describe('protocolColumn', () => { + it('renders a single-protocol key unchanged, with no hint', () => { + expect(protocolColumn(['anthropic'], 'anthropic', 'anthropic')).toEqual({ + primary: 'anthropic', + extraCount: 0, + }); + }); + + it('collapses a two-protocol key to the group protocol + (+1)', () => { + // The exact case from the reported screenshot: one VK bound to both an + // anthropic and an openai credential, previously printed "anthropic, openai". + expect(protocolColumn(['anthropic', 'openai'], 'openai', 'anthropic')).toEqual({ + primary: 'openai', + extraCount: 1, + }); + }); + + it('leads with THIS row\'s group, not the key\'s first protocol', () => { + // Regression guard: the openai row of an anthropic+openai key must not + // announce itself as "anthropic". + const { primary } = protocolColumn(['anthropic', 'openai'], 'openai', 'anthropic'); + expect(primary).toBe('openai'); + }); + + it('counts every extra protocol beyond the primary', () => { + expect(protocolColumn(['anthropic', 'openai', 'gemini'], 'anthropic', 'x')).toEqual({ + primary: 'anthropic', + extraCount: 2, + }); + }); + + it('falls back to the first protocol when the group is not among them', () => { + expect(protocolColumn(['anthropic', 'openai'], 'gemini', 'x')).toEqual({ + primary: 'anthropic', + extraCount: 1, + }); + }); + + it('falls back to the provider name when there are no protocols at all', () => { + // Legacy CLI payloads carry no bindings — must not render an empty cell. + expect(protocolColumn([], 'anthropic', 'zhipu')).toEqual({ + primary: 'zhipu', + extraCount: 0, + }); + }); + + it('ignores empty protocol entries rather than counting them as "more"', () => { + expect(protocolColumn(['anthropic', ''], 'anthropic', 'x')).toEqual({ + primary: 'anthropic', + extraCount: 0, + }); + }); +}); diff --git a/web/src/pages/user/_shared/protocol-column.ts b/web/src/pages/user/_shared/protocol-column.ts new file mode 100644 index 0000000..51eb48a --- /dev/null +++ b/web/src/pages/user/_shared/protocol-column.ts @@ -0,0 +1,33 @@ +/** + * PROTOCOLS-column label for a key row. + * + * A multi-protocol team key renders one row PER protocol group, so spelling the + * whole comma-joined list into every one of those rows both overflowed the 22% + * column and just re-stated the group header. Instead the column leads with the + * protocol of the group this row is rendered under and folds the remainder into + * a "(+N more)" hint (2026-07-23 user request); the full list stays reachable + * via the cell's title attribute and the detail drawer. + * + * Pure + exported so the collapsing rule is unit-testable without mounting the + * 7k-line vault page. + */ +export interface ProtocolColumn { + /** The single protocol to render as the cell's label. */ + primary: string; + /** How many further protocols this key speaks; 0 → render no hint. */ + extraCount: number; +} + +export function protocolColumn( + protocols: string[], + groupProvider: string | null | undefined, + fallback: string, +): ProtocolColumn { + const list = protocols.filter(Boolean); + if (list.length === 0) return { primary: fallback, extraCount: 0 }; + // Lead with THIS row's group when the key serves it — otherwise the openai + // row of an anthropic+openai key would announce itself as "anthropic". + const primary = + groupProvider && list.includes(groupProvider) ? groupProvider : list[0]; + return { primary, extraCount: Math.max(0, list.length - 1) }; +} diff --git a/web/src/pages/user/vault/index.tsx b/web/src/pages/user/vault/index.tsx index 8f6526a..1bd7dcc 100644 --- a/web/src/pages/user/vault/index.tsx +++ b/web/src/pages/user/vault/index.tsx @@ -56,6 +56,7 @@ import { } from '@/shared/components/DesktopConsentModal'; import { friendlyTestError } from './friendlyTestError'; import { displayProtocolFamily, familyOfProviderCode } from '@/shared/api/user/protocolFamily'; +import { protocolColumn } from '../_shared/protocol-column'; import type { BindingAxis } from '@/shared/types/team-vault'; import { SearchableSelect } from '@/shared/ui/SearchableSelect'; import { ProviderMultiSelect } from '@/shared/ui/ProviderMultiSelect'; @@ -2986,9 +2987,16 @@ const Row = React.memo(function Row(props: { // pre-P1f behavior (providerName). The provider goes in a chip next to the name. const teamBindings = r.target === 'team' ? (r as TeamRowRecord).bindings : undefined; const hasTwoAxis = Array.isArray(teamBindings) && teamBindings.length > 0; - const protocolLabel = hasTwoAxis - ? [...new Set(teamBindings!.map((b) => displayProtocolFamily(b.protocol)).filter(Boolean))].join(', ') - : providerName; + // Distinct protocol families this key speaks. The row is rendered under one + // protocol group, so surface THAT group's protocol first and collapse the + // rest into a "(+N more)" hint (2026-07-23 user request): the full comma list + // overflowed this 22% column and just re-stated the group header on every + // multi-protocol key. Full list stays reachable via the cell title + drawer. + const protocolList = hasTwoAxis + ? [...new Set(teamBindings!.map((b) => displayProtocolFamily(b.protocol)).filter(Boolean))] + : [providerName]; + const { primary: primaryProtocol, extraCount: extraProtocolCount } = + protocolColumn(protocolList, props.groupProvider, providerName); // Kind chip. Fully i18n'd (2026-07-07, user request): in Chinese the four // kinds render 密钥 / OAuth / 团队 / 团队 OAuth; English keeps KEY / OAUTH / // TEAM / TEAM-OAUTH. Values live in the vault namespace of the locale files. @@ -3194,7 +3202,14 @@ const Row = React.memo(function Row(props: { aria-hidden="true" /> {/* P1f: PROTOCOLS column shows the PROTOCOL axis (anthropic), not the provider. */} - {protocolLabel} + + {primaryProtocol} + {extraProtocolCount > 0 && ( + + {t('vault.protocolMore', { count: extraProtocolCount })} + + )} + {kindLabel} diff --git a/web/src/shared/i18n/locales/en/common.json b/web/src/shared/i18n/locales/en/common.json index e565e4f..8ffcd74 100644 --- a/web/src/shared/i18n/locales/en/common.json +++ b/web/src/shared/i18n/locales/en/common.json @@ -267,6 +267,7 @@ "aliasLabel": "Alias ", "aliasEditableHint": "editable", "colProtocols": "Protocols", + "protocolMore": "(+{{count}} more)", "colStatus": "Status", "colCreated": "Created", "colLastTest": "Last test", diff --git a/web/src/shared/i18n/locales/zh/common.json b/web/src/shared/i18n/locales/zh/common.json index ba2ed62..ed36eda 100644 --- a/web/src/shared/i18n/locales/zh/common.json +++ b/web/src/shared/i18n/locales/zh/common.json @@ -267,6 +267,7 @@ "aliasLabel": "别名 ", "aliasEditableHint": "可编辑", "colProtocols": "协议", + "protocolMore": "(+{{count}})", "colStatus": "状态", "colCreated": "创建时间", "colLastTest": "最近检测",