diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cd41b29eed..a77d81f80b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1507,6 +1507,22 @@ "unavailable": "Connection status unavailable." }, "agentByo": { + "harnessPicker": { + "label": "Harness", + "connects": "connects", + "mac": "Mac", + "linux": "Linux", + "windows": "Windows", + "connectsCommand": "one command", + "connectsTerminal": "terminal steps", + "privateOnly": "private pods only", + "osState": { + "walked": "Walked on {{os}}", + "mcp-only": "{{os}}: connects over MCP only, no @mention listener", + "not-walked": "Not walked on {{os}} yet", + "not-offered": "Not offered on {{os}}" + } + }, "title": "Bring your own agent", "description": "A running agent answers @mentions in its pod.", "form": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 1667dc2a2c..c6a27e0a09 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1567,6 +1567,22 @@ "unavailable": "暂时无法获取连接状态。" }, "agentByo": { + "harnessPicker": { + "label": "运行器", + "connects": "接入方式", + "mac": "Mac", + "linux": "Linux", + "windows": "Windows", + "connectsCommand": "一条命令", + "connectsTerminal": "终端步骤", + "privateOnly": "仅限私密 Pod", + "osState": { + "walked": "{{os}} 已验证", + "mcp-only": "{{os}}:仅通过 MCP 接入,不会监听 @提及", + "not-walked": "{{os}} 尚未验证", + "not-offered": "{{os}} 上不提供" + } + }, "title": "接入你自己的智能体", "description": "智能体运行期间会在所在 Pod 回复 @提及。", "form": { diff --git a/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx b/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx new file mode 100644 index 0000000000..3d41c77a75 --- /dev/null +++ b/frontend/src/v2/__tests__/V2HarnessPicker.test.tsx @@ -0,0 +1,101 @@ +// @ts-nocheck +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import V2HarnessPicker from '../components/V2HarnessPicker'; +import { HARNESS_WALK_TABLE, HIDDEN_HARNESS_IDS } from '../utils/harnessWalkTable'; +import fs from 'fs'; +import path from 'path'; + +const v2Css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); +// Body of the first rule whose selector list includes `selector` as a complete selector +// (not a prefix of a longer class name), so grouped selectors match too. +const ruleBody = (selector) => { + // Escape the selector, then require it to start a selector-list entry and end at a comma or + // an opening brace. A compound selector such as `.dot--walked.is-active` does not match. + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = v2Css.match(new RegExp(`(?:^|[,\\n])\\s*${escaped}\\s*(?=,|\\{)[^{}]*\\{([^}]*)\\}`)); + return match ? match[1] : ''; +}; + +afterEach(() => jest.clearAllMocks()); + +describe('V2HarnessPicker', () => { + it('renders exactly the walked harnesses from the walk table, in table order', () => { + render(); + const rows = within(screen.getByTestId('harness-picker')).getAllByRole('button'); + expect(rows.map((r) => r.getAttribute('data-testid'))).toEqual( + HARNESS_WALK_TABLE.map((row) => `harness-row-${row.id}`), + ); + expect(rows.map((r) => r.textContent.replace(/\s+/g, ' ').trim()).join('|')).toContain('Qwen Code'); + }); + + it('never renders a harness that has no walk on record', () => { + render(); + HIDDEN_HARNESS_IDS.forEach((id) => { + expect(screen.queryByTestId(`harness-row-${id}`)).toBeNull(); + }); + expect(screen.queryByText('Hermes')).toBeNull(); + expect(screen.queryByText('goose')).toBeNull(); + }); + + it('shows Qwen Code and hides Hermes, per the rev 3 walk table', () => { + render(); + expect(screen.getByTestId('harness-row-qwen')).toBeInTheDocument(); + expect(screen.queryByTestId('harness-row-hermes')).toBeNull(); + }); + + it('draws the OS dots from the table: filled when walked, dashed when not', () => { + render(); + const claude = screen.getByTestId('harness-row-claude'); + expect(within(claude).getByTestId('harness-dot-claude-mac')).toHaveAttribute('data-state', 'walked'); + expect(within(claude).getByTestId('harness-dot-claude-linux')).toHaveAttribute('data-state', 'mcp-only'); + expect(within(claude).getByTestId('harness-dot-claude-windows')).toHaveAttribute('data-state', 'mcp-only'); + expect(within(screen.getByTestId('harness-row-codex')).getByTestId('harness-dot-codex-linux')).toHaveAttribute('data-state', 'not-walked'); + }); + + it('gives each dot the state class the stylesheet keys on, for every state', () => { + render(); + expect(screen.getByTestId('harness-dot-claude-mac')).toHaveClass('v2-harness-picker__dot--walked'); + expect(screen.getByTestId('harness-dot-claude-linux')).toHaveClass('v2-harness-picker__dot--mcp-only'); + expect(screen.getByTestId('harness-dot-codex-linux')).toHaveClass('v2-harness-picker__dot--not-walked'); + expect(screen.getByTestId('harness-dot-dsh-windows')).toHaveClass('v2-harness-picker__dot--not-offered'); + }); + + it('titles each OS dot with why it is filled or dashed', () => { + render(); + expect(screen.getByTestId('harness-dot-claude-mac')).toHaveAttribute('title', 'Walked on Mac'); + expect(screen.getByTestId('harness-dot-claude-linux')).toHaveAttribute('title', 'Linux: connects over MCP only, no @mention listener'); + expect(screen.getByTestId('harness-dot-dsh-windows')).toHaveAttribute('title', 'Not offered on Windows'); + }); + + it('keeps the filled and dashed dot rules in v2.css', () => { + expect(ruleBody('.v2-harness-picker__dot--walked')).toContain('background: var(--v2-ink)'); + expect(ruleBody('.v2-harness-picker__dot--not-walked')).toContain('border: 1px dashed var(--v2-text-tertiary)'); + }); + + it('marks only DeepSeek Harness as private-pods-only', () => { + render(); + const privateRows = HARNESS_WALK_TABLE.filter((row) => row.privatePodsOnly).map((row) => row.id); + expect(privateRows).toEqual(['dsh']); + expect(within(screen.getByTestId('harness-row-dsh')).getByRole('img', { name: 'private pods only' })).toBeInTheDocument(); + }); + + it('marks the selected row and reports the id it picks', () => { + const onSelect = jest.fn(); + render(); + expect(screen.getByTestId('harness-row-codex')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('harness-row-claude')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(screen.getByTestId('harness-row-opencode')); + expect(onSelect).toHaveBeenCalledWith('opencode'); + }); + + it('pins the one-command rows to Claude Code and Codex', () => { + expect(HARNESS_WALK_TABLE.filter((row) => row.connects === 'command').map((row) => row.id)).toEqual(['claude', 'codex']); + }); + + it('uses the connect method from the table for each row', () => { + render(); + expect(screen.getByTestId('harness-connects-claude')).toHaveAttribute('aria-label', 'one command'); + expect(screen.getByTestId('harness-connects-opencode')).toHaveAttribute('aria-label', 'terminal steps'); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9d2d27a90b..36a34db620 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5150,3 +5150,17 @@ describe('v2 board on a phone (TASK-576)', () => { expect(page).toContain(''); }); }); + +describe('harness picker tracks (TASK-361)', () => { + const v2 = read('../v2.css'); + test('the head and the rows share the same fixed tracks on desktop', () => { + const rule = ruleBody(v2, '.v2-harness-picker__head,\n.v2-harness-picker__row'); + expect(rule).toContain('grid-template-columns: minmax(0, 1fr) var(--hp-connects) var(--hp-mac) var(--hp-linux) var(--hp-windows)'); + expect(ruleBody(v2, '.v2-harness-picker')).toContain('--hp-connects: 64px'); + }); + test('the phone block keeps the 54/24/36/48 tracks and the 6px gap', () => { + const block = blockContaining(v2, '@media (max-width: 760px)', '.v2-harness-picker__head'); + expect(block).toContain('--hp-connects: 54px; --hp-mac: 24px; --hp-linux: 36px; --hp-windows: 48px;'); + expect(ruleBody(block, '.v2-harness-picker__head,\n .v2-harness-picker__row')).toContain('gap: 6px'); + }); +}); diff --git a/frontend/src/v2/components/V2HarnessPicker.tsx b/frontend/src/v2/components/V2HarnessPicker.tsx new file mode 100644 index 0000000000..e7398cdf76 --- /dev/null +++ b/frontend/src/v2/components/V2HarnessPicker.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { HARNESS_WALK_TABLE, HarnessOsState, HarnessWalkRow } from '../utils/harnessWalkTable'; + +interface V2HarnessPickerProps { + selectedId: string; + onSelect: (id: string) => void; +} + +const GLYPH_PROPS = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, 'aria-hidden': true }; + +const ClipboardGlyph = () => ( + +); +const TerminalGlyph = () => ( + +); +const LockGlyph = () => ( + +); + +// One dot per OS. Filled when walked, dashed ring otherwise. The title says why. +const OsDot = ({ state, os, row }: { state: HarnessOsState; os: 'mac' | 'linux' | 'windows'; row: HarnessWalkRow }) => { + const { t } = useTranslation(); + const title = t(`agentByo.harnessPicker.osState.${state}`, { os: t(`agentByo.harnessPicker.${os}`) }); + return ( + + ); +}; + +const HarnessRow = ({ row, selected, onSelect }: { row: HarnessWalkRow; selected: boolean; onSelect: (id: string) => void }) => { + const { t } = useTranslation(); + const connectsLabel = t(row.connects === 'command' ? 'agentByo.harnessPicker.connectsCommand' : 'agentByo.harnessPicker.connectsTerminal'); + return ( + + ); +}; + +// Renders only the rows in HARNESS_WALK_TABLE. Hidden harnesses have no entry to render. +const V2HarnessPicker = ({ selectedId, onSelect }: V2HarnessPickerProps) => { + const { t } = useTranslation(); + return ( +
+ + {HARNESS_WALK_TABLE.map((row) => ( + + ))} +
+ ); +}; + +export default V2HarnessPicker; diff --git a/frontend/src/v2/utils/harnessWalkTable.ts b/frontend/src/v2/utils/harnessWalkTable.ts new file mode 100644 index 0000000000..c7538c4ab7 --- /dev/null +++ b/frontend/src/v2/utils/harnessWalkTable.ts @@ -0,0 +1,73 @@ +// The harness walk table, as data. Source of truth: ~/agents/shared/d15/harness-walk-table.md +// (ux-lead, 2026-10-27). A harness is listed in the picker only when it has a passing walk on +// record. Change a row here only with a new walk on record, and update the table to match. + +/** + * walked: passing walk on record. mcp-only: connects, but no @mention listener on this OS. + * not-walked: no walk yet. not-offered: the harness does not ship for this OS. + */ +export type HarnessOsState = 'walked' | 'mcp-only' | 'not-walked' | 'not-offered'; + +export interface HarnessWalkRow { + id: string; + label: string; + /** Two-letter stand-in until the vendor's own mark is wired in. */ + monogram: string; + /** `command` = one clipboard command; `terminal` = steps in your terminal. */ + connects: 'command' | 'terminal'; + os: { mac: HarnessOsState; linux: HarnessOsState; windows: HarnessOsState }; + /** Private pods only (lock glyph). */ + privatePodsOnly: boolean; + walkedOn: string; +} + +export const HARNESS_WALK_TABLE: readonly HarnessWalkRow[] = [ + { + id: 'claude', + label: 'Claude Code', + monogram: 'CC', + connects: 'command', + os: { mac: 'walked', linux: 'mcp-only', windows: 'mcp-only' }, + privatePodsOnly: false, + walkedOn: '2026-10-26', + }, + { + id: 'codex', + label: 'Codex', + monogram: 'CX', + connects: 'command', + os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' }, + privatePodsOnly: false, + walkedOn: '2026-10-27', + }, + { + id: 'opencode', + label: 'OpenCode', + monogram: 'OC', + connects: 'terminal', + os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' }, + privatePodsOnly: false, + walkedOn: '2026-10-27', + }, + { + id: 'qwen', + label: 'Qwen Code', + monogram: 'QW', + connects: 'terminal', + os: { mac: 'walked', linux: 'not-walked', windows: 'not-walked' }, + privatePodsOnly: false, + walkedOn: '2026-10-27', + }, + { + id: 'dsh', + label: 'DeepSeek Harness', + monogram: 'DS', + connects: 'terminal', + os: { mac: 'walked', linux: 'not-offered', windows: 'not-offered' }, + privatePodsOnly: true, + walkedOn: '2026-10-23', + }, +]; + +/** Harnesses with no passing walk on record. They never render in the picker. */ +export const HIDDEN_HARNESS_IDS: readonly string[] = ['hermes', 'goose', 'pi', 'gemini', 'grok-build']; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 23f9de9860..57c7aec2ba 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -13346,3 +13346,56 @@ body.modern-ui.v2-canvas { .v2-legal__section { margin-bottom: 28px; } .v2-legal__section h2 { font-size: 18px; font-weight: 600; margin: 0 0 8px; } .v2-legal__section p { font-size: 15px; line-height: 1.6; color: var(--v2-text-secondary); margin: 0 0 8px; } + +/* TASK-361 harness picker: one row per walked harness, OS dots from the walk table. */ +/* The head and every row share the same fixed tracks, so each head sits over its dot. */ +.v2-harness-picker { --hp-connects: 64px; --hp-mac: 60px; --hp-linux: 60px; --hp-windows: 60px; display: flex; flex-direction: column; gap: 8px; } +.v2-harness-picker__head, +.v2-harness-picker__row { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--hp-connects) var(--hp-mac) var(--hp-linux) var(--hp-windows); + align-items: center; + gap: 16px; +} +.v2-harness-picker__head { padding: 0 16px; font-family: var(--v2-font-mono); font-size: 12px; color: var(--v2-text-tertiary); } +.v2-harness-picker__head > span { white-space: nowrap; text-align: center; } +.v2-harness-picker__row { + width: 100%; + padding: 14px 16px; + background: var(--v2-surface); + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + color: var(--v2-text-primary); + font: inherit; + text-align: left; + cursor: pointer; +} +.v2-harness-picker__row:hover { background: var(--v2-surface-hover); } +.v2-harness-picker__row--selected { border-color: var(--v2-accent); } +.v2-harness-picker__name { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; font-size: 15px; font-weight: 500; } +.v2-harness-picker__mark { + display: inline-grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + font-family: var(--v2-font-mono); + font-size: 11px; +} +.v2-harness-picker__lock { display: inline-flex; color: var(--v2-text-tertiary); } +.v2-harness-picker__connects { display: inline-flex; justify-self: center; color: var(--v2-text-secondary); } +.v2-harness-picker__dot { justify-self: center; width: 8px; height: 8px; border-radius: 50%; } +.v2-harness-picker__dot--walked { background: var(--v2-ink); } +.v2-harness-picker__dot--mcp-only, +.v2-harness-picker__dot--not-walked, +.v2-harness-picker__dot--not-offered { border: 1px dashed var(--v2-text-tertiary); } + +@media (max-width: 760px) { + .v2-harness-picker { --hp-connects: 54px; --hp-mac: 24px; --hp-linux: 36px; --hp-windows: 48px; } + .v2-harness-picker__head, + .v2-harness-picker__row { gap: 6px; padding-left: 12px; padding-right: 12px; } + .v2-harness-picker__head { font-family: var(--v2-font-mono); font-size: 11px; } + .v2-harness-picker__name { font-size: 14px; } +}