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
56 changes: 56 additions & 0 deletions web/src/pages/user/_shared/protocol-column.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
33 changes: 33 additions & 0 deletions web/src/pages/user/_shared/protocol-column.ts
Original file line number Diff line number Diff line change
@@ -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) };
}
23 changes: 19 additions & 4 deletions web/src/pages/user/vault/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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. */}
<span className="name">{protocolLabel}</span>
<span className="name" title={protocolList.join(', ')}>
{primaryProtocol}
{extraProtocolCount > 0 && (
<span style={{ marginLeft: 4, opacity: 0.6 }}>
{t('vault.protocolMore', { count: extraProtocolCount })}
</span>
)}
</span>
<span className={`kind-pill${kindClass}`}>{kindLabel}</span>
</span>
</td>
Expand Down
1 change: 1 addition & 0 deletions web/src/shared/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
"aliasLabel": "Alias ",
"aliasEditableHint": "editable",
"colProtocols": "Protocols",
"protocolMore": "(+{{count}} more)",
"colStatus": "Status",
"colCreated": "Created",
"colLastTest": "Last test",
Expand Down
1 change: 1 addition & 0 deletions web/src/shared/i18n/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
"aliasLabel": "别名 ",
"aliasEditableHint": "可编辑",
"colProtocols": "协议",
"protocolMore": "(+{{count}})",
"colStatus": "状态",
"colCreated": "创建时间",
"colLastTest": "最近检测",
Expand Down
Loading