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
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1874,6 +1874,9 @@
"kickerAttention": "attention",
"kickerMirror": "mirror",
"kickerOff": "relay off",
"rowAttention": "escalations reach the channel",
"rowMirror": "every agent line reaches the channel",
"rowRelayOff": "messages stay in the pod",
"age": {
"added": "added {{age}}",
"started": "started {{age}}",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,9 @@
"kickerAttention": "提醒",
"kickerMirror": "镜像",
"kickerOff": "中继已关",
"rowAttention": "升级消息会回到频道",
"rowMirror": "智能体的每条消息都会回到频道",
"rowRelayOff": "消息只留在 Pod 内",
"age": {
"added": "{{age}}前添加",
"started": "{{age}}前开始",
Expand Down
44 changes: 42 additions & 2 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';
import en from '../../i18n/locales/en.json';

jest.mock('axios', () => {
const mock = {
Expand Down Expand Up @@ -109,11 +110,47 @@ describe('V2ConnectorsPage', () => {
const mark = row?.querySelector('.v2-connector-row__mark');
expect(mark).not.toBeNull();
expect(mark).toHaveAttribute('role', 'img');
expect(mark?.getAttribute('aria-label')).toMatch(/attention|every agent line|relay off/);
// TASK-162 (3): the glyph carries the MODE WORD (what the mark means); the
// consequence is the visible words beside it, so line 3 is never a bare mark.
expect(mark?.getAttribute('aria-label')).toMatch(/^(attention|mirror|relay off)$/);
expect(mark?.getAttribute('title')).toBe(mark?.getAttribute('aria-label'));
expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).toBe('messages stay in the pod');
// The mode word is NOT repeated on line 3: at ≤760 the kicker carries it, so
// 'relay off · messages stay in the pod' said it twice (TASK-162 UX gate).
expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).not.toMatch(/^(attention|mirror|relay off) ·/);
expect(row?.querySelector('.v2-connector-row__kicker-mode')?.textContent).toMatch(/attention|mirror|relay off/);
expect(row?.querySelector('.v2-connector-row__when')).toBeNull();
});

it('TASK-162: line 3 never repeats the mode word the kicker already carries', async () => {
// The prefixes lived in the component's defaultValue, which is exactly where
// they could not be guarded: once the key exists in the catalog the catalog
// wins, so reverting the defaultValue changes nothing rendered. The copy now
// lives in en.json, so that is where the rule is asserted.
const kickerByRow: Array<[keyof typeof en.connectors, string]> = [
['rowAttention', 'attention'],
['rowMirror', 'mirror'],
['rowRelayOff', 'relay off'],
];
for (const [key, modeWord] of kickerByRow) {
expect(en.connectors[key]).toBeTruthy();
expect(en.connectors[key].startsWith(modeWord)).toBe(false);
}
});

it('TASK-162: the not-yet channel names are separate elements, with a separator that only shows at ≤760', async () => {
mockGets([]);
const { container } = renderPage();
await waitFor(() => expect(container.querySelector('.v2-connector-row--not-yet')).not.toBeNull());
const names = container.querySelector('.v2-connector-row--not-yet .v2-connector-row__names');
expect(names).not.toBeNull();
// Separate elements, not one joined string: above 760 they stack in the name
// track, so a joined string would be a single 140px-wide line that runs into
// the details column.
expect(Array.from(names!.querySelectorAll('.v2-connector-row__name-item')).map((item) => item.textContent)).toEqual(['Discord', 'WhatsApp']);
expect(names!.querySelectorAll('.v2-connector-row__name-sep')).toHaveLength(1);
});

it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => {
// Fake the clock so the minute tick is deterministic. The fixture's age
// only moves if the page re-renders it, which is the defect being pinned.
Expand Down Expand Up @@ -145,7 +182,10 @@ describe('V2ConnectorsPage', () => {
expect(screen.getByText('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument();
expect(screen.getByText('Code expires in 5 min')).toBeInTheDocument();
expect(screen.getByText('Rewire crew · linked to Ops')).toBeInTheDocument();
expect(screen.getByText('Discord · WhatsApp')).toBeInTheDocument();
// TASK-162 (2): the two not-yet names are separate elements that stack in the
// name track above 760 and join with ' · ' at ≤760 — no longer one string.
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('WhatsApp')).toBeInTheDocument();
expect(screen.getByText('/commonly-enable abc1 23')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy command' })).toBeInTheDocument();
expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(3);
Expand Down
44 changes: 42 additions & 2 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1822,14 +1822,54 @@ describe('v2 layout invariants (CSS rule presence)', () => {
// Direction A (2026-09-19): three tracks — the age moved into the kicker.
expect(ruleBody(v2, '.v2-connector-row')).toContain('grid-template-columns: 140px minmax(150px, 1fr) 120px');
expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('grid-template-columns: 140px minmax(150px, 1fr)');
// Rule 3: the kicker is mono 11; rule 1: the mark is 16px and the mode word is hidden until 760; rule 2: the gear is 32 (44 on the phone).
// TASK-162 (2): the name track ends exactly where the details track begins, so
// a name that fills it touches the sentence. The gutter has to be on BOTH row
// grids — the article's and the selection button's inner one.
expect(ruleBody(v2, '.v2-connector-row')).toContain('column-gap: 12px');
expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('column-gap: 12px');
// TASK-162 (1): the ink hover must NOT claim a secondary action, or a hovered
// gear is ink behind an ink glyph. Asserted on the SELECTOR rather than on a
// computed style: jsdom resolves no :hover rules at all, so the cascade this
// bug lived in cannot be measured in-suite — only the shape that fixes it can.
const inkHoverChunks = [...v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
.filter(([, , body]) => body.includes('var(--v2-ink-hover)'))
.flatMap(([, selector]) => selector.split(','))
.map((selector) => selector.trim())
.filter((selector) => selector.includes('.v2-connector-row__action:hover'));
// Split per SELECTOR, not per rule: both variants share one declaration block,
// so a rule-level check is satisfied by whichever selector kept the exclusion
// and reads green with the button — the gear that actually broke — still
// claiming secondary. Two separate assertions, one per line.
expect(inkHoverChunks).toHaveLength(2);
expect(inkHoverChunks.find((selector) => selector.includes('button.v2-connector-row__action:hover'))).toContain(':not(:disabled)');
for (const selector of inkHoverChunks) {
expect(selector).toContain(':not(.v2-connector-row__action--secondary)');
}
// TASK-162 (3): line 3 is the consequence as TEXT beside the mark (the mode
// word moved onto the mark itself) and the not-yet names stack above 760.
expect(ruleBody(v2, '.v2-connector-row__names')).toContain('flex-direction: column');
// …and the mark SHARES the Tools glyph's rule rather than carrying a second
// rule of its own. The two rows had drifted 16 vs 14, gap 8 vs 6, secondary vs
// tertiary; a rule of its own can always drift again, so the guard counts the
// rules that mention the mark: the shared one and the ≤760 hide, nothing else.
const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, '');
const markRuleChunks = [...stripComments(v2).matchAll(/([^{}]+)\{([^{}]*)\}/g)]
.filter(([, selector]) => selector.includes('.v2-connector-row__mark'));
expect(markRuleChunks).toHaveLength(2);
const sharedMarkRule = markRuleChunks.find(([, selector]) => selector.includes('.v2-tools__mode'));
expect(sharedMarkRule?.[2]).toContain('display: inline-flex');
expect(sharedMarkRule?.[2]).toContain('vertical-align: -2px');
expect(sharedMarkRule?.[2]).toContain('color: var(--v2-text-tertiary)');
// Rule 3: the kicker is mono 11; rule 1 (as revised by the TASK-162 gate): the mode word is hidden until 760 and the mark's own 16px rule is GONE — its size now comes from the shared rule above plus the glyph's `size` prop; rule 2: the gear is 32 (44 on the phone).
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('var(--v2-font-mono)');
expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('font-size: 11px');
expect(ruleBody(v2, '.v2-connector-row__kicker-mode')).toContain('display: none');
expect(ruleBody(v2, '.v2-connector-row__mark')).toContain('width: 16px');
expect(v2).not.toContain('width: 16px; height: 16px; color: var(--v2-text-secondary)');
expect(ruleBody(v2, '.v2-root button.v2-connector-row__action--icon')).toContain('width: 32px');
const phone760 = v2.slice(v2.indexOf('@media (max-width: 760px) {\n .v2-connectors {'));
expect(phone760).toContain('.v2-connector-row__kicker-mode { display: inline; }');
expect(phone760).toContain('.v2-connector-row__names { display: block; }');
expect(phone760).toContain('.v2-connector-row__name-sep { display: inline; }');
expect(phone760).toContain('.v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px;');
// TASK-140: the aside's Revoke ✕ carries `--secondary` as well as `--icon`,
// and `--secondary` sets `min-height: 36px`. Because min-height beats height,
Expand Down
21 changes: 17 additions & 4 deletions frontend/src/v2/components/V2ConnectorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -727,8 +727,8 @@ const V2ConnectorsPage: React.FC = () => {
const markLabel = relay
? (mirror
? t('connectors.rowMirror', { defaultValue: 'every agent line reaches the channel' })
: t('connectors.rowAttention', { defaultValue: 'attention · escalations reach the channel' }))
: t('connectors.rowRelayOff', { defaultValue: 'relay off · messages stay in the pod' });
: t('connectors.rowAttention', { defaultValue: 'escalations reach the channel' }))
: t('connectors.rowRelayOff', { defaultValue: 'messages stay in the pod' });
return {
action: (isTelegram || isSlack) ? 'manage' : null,
actionLabel: t('connectors.manage', { defaultValue: 'Manage' }),
Expand Down Expand Up @@ -1064,8 +1064,14 @@ const V2ConnectorsPage: React.FC = () => {
</span>
<strong>{row.line}</strong>
{row.mark ? (
// TASK-162 (3): the consequence is the visible words; the mode word
// (mirror / attention / relay off) is what the glyph itself means,
// so it rides title + aria-label rather than being the only thing
// on the line. The glyph rides .v2-tools__mode's 14px rule (see
// v2.css): #1782 says the channel mark follows the Tools glyph.
<span className="v2-connector-row__detail">
<span className="v2-connector-row__mark" title={row.mark.label} role="img" aria-label={row.mark.label}><MarkGlyph name={row.mark.name} /></span>
<span className="v2-connector-row__mark" title={row.mark.word} role="img" aria-label={row.mark.word}><MarkGlyph name={row.mark.name} size={14} /></span>
<span className="v2-connector-row__mark-text">{row.mark.label}</span>
</span>
) : (
<span className="v2-connector-row__detail">{row.detail}</span>
Expand Down Expand Up @@ -1486,7 +1492,14 @@ const V2ConnectorsPage: React.FC = () => {
<span className="v2-connector-row__name">
<span className="v2-connector-row__dot v2-connector-row__dot--not-yet" aria-hidden="true" />
<span className="v2-connector-row__glyph" aria-hidden="true"><PlatformGlyph type="discord" /></span>
<span>{UNAVAILABLE_PLATFORM_LABELS.join(' · ')}</span>
<span className="v2-connector-row__names">
{UNAVAILABLE_PLATFORM_LABELS.map((name, index) => (
<React.Fragment key={name}>
{index > 0 && <span className="v2-connector-row__name-sep" aria-hidden="true"> · </span>}
<span className="v2-connector-row__name-item">{name}</span>
</React.Fragment>
))}
</span>
</span>
<span className="v2-connector-row__details">
<span className="v2-connector-row__kicker">{t('connectors.notYetKicker', { defaultValue: 'not yet' })}</span>
Expand Down
39 changes: 33 additions & 6 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -9890,6 +9890,11 @@ body.modern-ui.v2-canvas {
/* Direction A (2026-09-19): the age column is gone — it rides the mono kicker
above line 1 — so the row is name · details · one act. */
grid-template-columns: 140px minmax(150px, 1fr) 120px;
/* TASK-162 (2): the channel name and its details met at 0px — the 140px name
track ends exactly where the details track begins, so a name that fills its
track touches the sentence. A 12px gutter on BOTH row grids (here and the
selection button's inner grid below) keeps the two columns apart. */
column-gap: 12px;
align-items: center;
min-height: 82px;
padding: 16px 18px;
Expand All @@ -9903,6 +9908,7 @@ body.modern-ui.v2-canvas {
display: grid;
grid-column: 1 / 3;
grid-template-columns: 140px minmax(150px, 1fr);
column-gap: 12px;
align-items: center;
min-width: 0;
padding: 0;
Expand Down Expand Up @@ -9941,14 +9947,26 @@ body.modern-ui.v2-canvas {
.v2-connector-row__detail, .v2-connector-row__when { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; }
/* Meta wraps to a second line; it never ellipsises (lily-shen, 64026). */
.v2-connector-row__detail { overflow-wrap: anywhere; }
/* TASK-162 (3): line 3 used to be the bare 16px mark, its consequence words
reachable only through the glyph's tooltip — and at ≤760 the mark is hidden,
so line 3 disappeared entirely on a phone. The consequence is now TEXT beside
the mark; the mode word moved onto the mark itself (title + aria-label), and
the glyph itself follows #1782: it takes the Tools glyph's rule below (14px,
gap 6, tertiary, vertical-align -2px) rather than a 16px rule of its own,
because beside a 12/16 mono sentence a 16px mark grows the line box to 18px. */
/* TASK-162 (2): the not-yet row lists two channels. Above 760 they stack in the
140px name track; at ≤760 they join with ' · ' (the separator is the only
element that switches, so nothing here depends on generated content). */
.v2-connector-row__names { display: flex; flex-direction: column; }
.v2-connector-row__name-sep { display: none; }
.v2-connector-row__when { padding-right: 16px; }
/* Direction A rule 3: `pod · verb age` in mono 11 above line 1, on every row. */
.v2-connector-row__kicker { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; font-weight: 500; line-height: 16px; text-transform: lowercase; overflow-wrap: anywhere; }
/* The mode word rides the kicker only where the glyph's tooltip cannot (≤760). */
.v2-connector-row__kicker-mode { display: none; }
/* Direction A rule 1: a category on the row is a 16px mark, the sentence in title + aria-label. */
.v2-connector-row__mark { display: inline-flex; width: 16px; height: 16px; color: var(--v2-text-secondary); vertical-align: middle; }
.v2-connector-row__mark svg { width: 16px; height: 16px; }
/* Direction A rule 1 (as revised by TASK-162): a category on the row is a mark,
the sentence in title + aria-label — and the mark shares the Tools glyph's rule
(search `.v2-connector-row__mark, .v2-tools__mode`) so the two rows cannot drift. */
.v2-root button.v2-connector-row__action,
.v2-root a.v2-connector-row__action {
justify-self: end;
Expand All @@ -9962,8 +9980,15 @@ body.modern-ui.v2-canvas {
text-decoration: none;
cursor: pointer;
}
.v2-root button.v2-connector-row__action:hover:not(:disabled),
.v2-root a.v2-connector-row__action:hover { background: var(--v2-ink-hover); }
/* TASK-162 (1): a hovered secondary action turned INK behind an ink glyph —
`.v2-root button....__action:hover:not(:disabled)` (0,4,1) outranks the
`--secondary:hover` tint (0,3,1), so the gear read as a solid black square
with an invisible glyph. Excluding secondary from the ink rule fixes it
WITHOUT relying on source order: raising the tint's specificity would work
only until someone moves a rule, and the `.v2-root` prefix that every
selector here carries makes order-fragile fixes easy to write by accident. */
.v2-root button.v2-connector-row__action:hover:not(:disabled):not(.v2-connector-row__action--secondary),
.v2-root a.v2-connector-row__action:hover:not(.v2-connector-row__action--secondary) { background: var(--v2-ink-hover); }
.v2-root button.v2-connector-row__action:disabled { cursor: progress; opacity: 0.65; }
.v2-root button.v2-connector-row__action--secondary,
.v2-root a.v2-connector-row__action--secondary { border-color: var(--v2-border); background: var(--v2-surface); color: var(--v2-ink); }
Expand Down Expand Up @@ -10095,7 +10120,7 @@ body.modern-ui.v2-canvas {
.v2-tools__trail-line { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--v2-border-soft); color: var(--v2-text-muted); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; }
.v2-tools__trail-line > span:first-child { min-width: 0; overflow-wrap: anywhere; }
.v2-tools__trail-when { flex: none; }
.v2-tools__mode, .v2-tools__outcome { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); }
.v2-tools__mode, .v2-tools__outcome, .v2-connector-row__mark { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); }
.v2-tools__form { display: grid; gap: 14px; margin-top: 14px; }
.v2-tools__field { display: grid; gap: 6px; margin: 0; padding: 0; border: none; min-width: 0; }
.v2-tools__field > span:first-child, .v2-tools__field > legend { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; text-transform: lowercase; padding: 0; }
Expand Down Expand Up @@ -10134,6 +10159,8 @@ body.modern-ui.v2-canvas {
.v2-root button.v2-connector-row__selection { grid-row: 1; }
.v2-connector-row__kicker-mode { display: inline; }
.v2-connector-row__mark { display: none; }
.v2-connector-row__names { display: block; }
.v2-connector-row__name-sep { display: inline; }
.v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px; grid-column: 2; grid-row: 1; justify-self: end; align-self: start; }
.v2-root button.v2-connector-row__action:not(.v2-connector-row__action--icon), .v2-root a.v2-connector-row__action { display: inline-flex; grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: center; min-height: 44px; margin-top: 8px; }
/* The gear sits over the selection button's top-right; keep the kicker out from under it. */
Expand Down
Loading