diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 05ed97a7b..a66baa26e 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -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}}",
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 3d861a98a..2c25896f6 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -1866,6 +1866,9 @@
"kickerAttention": "提醒",
"kickerMirror": "镜像",
"kickerOff": "中继已关",
+ "rowAttention": "升级消息会回到频道",
+ "rowMirror": "智能体的每条消息都会回到频道",
+ "rowRelayOff": "消息只留在 Pod 内",
"age": {
"added": "{{age}}前添加",
"started": "{{age}}前开始",
diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
index c0f1df3e2..94d90b450 100644
--- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
+++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
@@ -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 = {
@@ -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.
@@ -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);
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 007ab92c9..f20f47ed4 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -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,
diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx
index ba299b41b..57487b2bb 100644
--- a/frontend/src/v2/components/V2ConnectorsPage.tsx
+++ b/frontend/src/v2/components/V2ConnectorsPage.tsx
@@ -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' }),
@@ -1064,8 +1064,14 @@ const V2ConnectorsPage: React.FC = () => {
{row.line}
{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.
-
+
+ {row.mark.label}
) : (
{row.detail}
@@ -1486,7 +1492,14 @@ const V2ConnectorsPage: React.FC = () => {
- {UNAVAILABLE_PLATFORM_LABELS.join(' · ')}
+
+ {UNAVAILABLE_PLATFORM_LABELS.map((name, index) => (
+
+ {index > 0 && · }
+ {name}
+
+ ))}
+
{t('connectors.notYetKicker', { defaultValue: 'not yet' })}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index e4e58b14f..ffeeefe59 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -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;
@@ -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;
@@ -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;
@@ -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); }
@@ -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; }
@@ -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. */