diff --git a/frontend/src/components/AppsManagement.test.tsx b/frontend/src/components/AppsManagement.test.tsx index c28e1b9751..ec0cdf7a50 100644 --- a/frontend/src/components/AppsManagement.test.tsx +++ b/frontend/src/components/AppsManagement.test.tsx @@ -88,4 +88,19 @@ describe('AppsManagement ingest tokens', () => { expect(screen.getByText('Commonly Apps').parentElement?.querySelector('svg')).not.toBeInTheDocument(); expect(screen.getByText('Existing Integrations').parentElement?.querySelector('svg')).not.toBeInTheDocument(); }); + + it('skips the integrations fetch when Settings only needs developer apps', async () => { + render( + + + , + ); + + await screen.findByText('Commonly Apps'); + await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/apps', { + headers: { Authorization: 'Bearer t' }, + })); + expect(axios.get.mock.calls.some(([url]) => url === '/api/integrations/user/all')).toBe(false); + expect(screen.queryByText('Existing Integrations')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/AppsManagement.tsx b/frontend/src/components/AppsManagement.tsx index 4f2ab04320..b9db5fcd03 100644 --- a/frontend/src/components/AppsManagement.tsx +++ b/frontend/src/components/AppsManagement.tsx @@ -96,6 +96,9 @@ interface AppsManagementProps { * the same functional controls without duplicating its page-level heading. */ variant?: 'default' | 'settings'; + /** Settings keeps connector rows on the Connectors page; this surface only + * needs the developer-app forms inside its collapsed disclosure. */ + showIntegrations?: boolean; } interface AppSurfaceProps { @@ -114,7 +117,7 @@ const AppSurface: React.FC = ({ children, className, outlined = : {children} ); -const AppsManagement: React.FC = ({ variant = 'default' }) => { +const AppsManagement: React.FC = ({ variant = 'default', showIntegrations = true }) => { const isSettingsVariant = variant === 'settings'; const { user } = useContext(AuthContext); const navigate = useNavigate(); @@ -176,9 +179,10 @@ const AppsManagement: React.FC = ({ variant = 'default' }) }; useEffect(() => { - fetchIntegrations(); + if (showIntegrations) fetchIntegrations(); + else setLoading(false); fetchApps(); - }, [user]); + }, [user, showIntegrations]); const handleDeleteClick = (integration: Integration): void => { setSelectedIntegration(integration); @@ -568,7 +572,7 @@ const AppsManagement: React.FC = ({ variant = 'default' }) - + {showIntegrations && {!isSettingsVariant && } @@ -642,7 +646,7 @@ const AppsManagement: React.FC = ({ variant = 'default' }) )} - + } {!isSettingsVariant && ( diff --git a/frontend/src/i18n/__tests__/i18n.test.ts b/frontend/src/i18n/__tests__/i18n.test.ts index 9d64cdd06e..861a879652 100644 --- a/frontend/src/i18n/__tests__/i18n.test.ts +++ b/frontend/src/i18n/__tests__/i18n.test.ts @@ -122,4 +122,10 @@ describe('i18n configuration', () => { expect(i18n.t('auth.register.success.checkEmailTitle', { lng: 'zh-CN' })).toBe('欢迎加入'); expect(`${banner}${screen}`).not.toMatch(/群组|—/); }); + + it('uses a Chinese comma in the BYO machine hint', () => { + const hint = i18n.t('agentByo.mode.machineHint', { lng: 'zh-CN' }); + expect(hint).toBe('在你已添加的电脑上运行,你的文件、你的 CLI'); + expect(hint).not.toContain('—'); + }); }); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 41aecd7693..55d6fe9070 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1469,12 +1469,16 @@ "mode": { "label": "Where should it run?", "hosted": "Run it here", + "hostedHint": "Commonly runs it for you. Nothing to install. Beta: {{turns}} turns a day.", "byo": "Use my own agent", + "byoHint": "Your Claude Code or Codex, on your computer.", "onCommonly": "on Commonly", "yours": "your machine", "hostedMeta": "Live in a few seconds · Beta: {{turns}} turns a day", "byoMeta": "One command in Terminal", + "moreAbout": "More about {{mode}}", "machine": "On my computer", + "machineHint": "Runs on a computer you've added — your files, your CLIs", "machineMeta": "Needs the Commonly daemon running there" }, "hosted": { @@ -1493,8 +1497,7 @@ "draft": "not created yet", "waiting": "not listening yet", "starting": "starting…", - "live": "listening", - "noteMachine": "Appears when the daemon on {{machine}} starts it." + "live": "listening" }, "persona": { "hiring": "Hiring" diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 5f5c40ce98..61066a8d16 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1529,12 +1529,16 @@ "mode": { "label": "在哪里运行?", "hosted": "在这里运行", + "hostedHint": "由 Commonly 代为运行,无需安装。测试期:每天{{turns}}轮。", "byo": "用我自己的智能体", + "byoHint": "你电脑上的 Claude Code 或 Codex。", "onCommonly": "在 Commonly 上", "yours": "你的电脑", "hostedMeta": "几秒即可上线 · 测试期每天{{turns}}轮", "byoMeta": "只需一条终端命令", + "moreAbout": "更多关于{{mode}}的信息", "machine": "在我的电脑上", + "machineHint": "在你已添加的电脑上运行,你的文件、你的 CLI", "machineMeta": "需要那台电脑运行 Commonly 守护进程" }, "hosted": { @@ -1553,8 +1557,7 @@ "draft": "尚未创建", "waiting": "尚未监听", "starting": "正在启动…", - "live": "监听中", - "noteMachine": "{{machine}} 上的守护进程启动后会显示在 Pod 中。" + "live": "监听中" }, "persona": { "hiring": "正在雇佣" diff --git a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx index f4afd46a2f..28d3c8e3fc 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOHosted.test.tsx @@ -77,6 +77,8 @@ describe('V2AgentBYO — Run it here', () => { await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument()); expect(screen.getByRole('group', { name: 'Where should it run?' })).toBeInTheDocument(); + expect(screen.getByText('Live in a few seconds · Beta: 200 turns a day')).toBeInTheDocument(); + expect(document.body.textContent).not.toContain('{{'); expect(screen.getByTestId('byo-mode-hosted')).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByTestId('byo-mode-byo')).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByTestId('byo-mode-unchosen')).toHaveTextContent('Choose where it runs first'); @@ -128,7 +130,7 @@ describe('V2AgentBYO — Run it here', () => { renderPage(); await waitFor(() => expect(screen.getByTestId('byo-mode-hosted')).toBeInTheDocument()); - expect(screen.getByText(/beta: 200 turns a day/i)).toBeInTheDocument(); + expect(screen.getAllByText(/beta: 200 turns a day/i)).toHaveLength(2); fireEvent.click(screen.getByTestId('byo-mode-hosted')); fireEvent.click(screen.getByRole('button', { name: /^run it here$/i })); diff --git a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx index 22b21f0363..5ca3fcd67d 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOMachine.test.tsx @@ -78,29 +78,43 @@ describe('BYO on-my-computer mode', () => { expect(screen.getByTestId('byo-machine-select')).toHaveTextContent('Sam’s MacBook'); }); - test('the preview note names the daemon machine in "on my computer" mode (#TASK-163)', async () => { - // Before TASK-163 the note read "Appears in the pod once your runtime - // connects with the token" in EVERY non-hosted mode, and this mode issues - // no token at all — ux-lead measured it drawing README frame 5. The name - // is derived once from machines+machineId, so this pins that it FOLLOWS - // the picker rather than merely being present. + test('adapter and model edits do not clear an unrelated field error', async () => { + mockGet(); + renderPage(); + await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('byo-mode-machine')); + await waitFor(() => expect(screen.getByText('Add to this computer')).toBeEnabled()); + + fireEvent.change(screen.getByPlaceholderText('my-mcp-agent'), { target: { value: ' ' } }); + fireEvent.click(screen.getByText('Add to this computer')); + + const nameField = screen.getByPlaceholderText('my-mcp-agent').closest('label'); + expect(nameField).toHaveClass('v2-byo__field--error'); + + fireEvent.change(screen.getByTestId('byo-adapter-select'), { target: { value: 'codex' } }); + expect(nameField).toHaveClass('v2-byo__field--error'); + fireEvent.change(screen.getByTestId('byo-model-select'), { target: { value: 'gpt-6-sol' } }); + expect(nameField).toHaveClass('v2-byo__field--error'); + }); + + test('the preview stays visual-only when the mode or machine changes', async () => { const second = { id: 'm2', machineId: 'mach-b', name: 'Studio', status: 'online', agentStates: [] }; mockGet({ machines: [machineRow, second] }); renderPage(); await waitFor(() => expect(screen.getByTestId('byo-mode-machine')).toBeInTheDocument()); - // The preview's name and status carry the meaning until a machine is chosen. - expect(screen.getByTestId('byo-preview').querySelector('.v2-byo__preview-note')).toBeNull(); + const preview = screen.getByTestId('byo-preview'); + expect(preview).not.toHaveTextContent('This is how it will appear in the pod. The face follows the name.'); // On "Bring your own runtime", the token wording is correct. fireEvent.click(screen.getByTestId('byo-mode-byo')); - expect(screen.getByTestId('byo-preview').querySelector('.v2-byo__preview-note')).toBeNull(); + expect(preview).not.toHaveTextContent('Appears in the pod once the command is running.'); fireEvent.click(screen.getByTestId('byo-mode-machine')); - expect(screen.getByTestId('byo-preview')).toHaveTextContent('Appears when the daemon on Sam’s MacBook starts it.'); + expect(preview).not.toHaveTextContent('Appears in the pod once the daemon on Sam’s MacBook starts it.'); fireEvent.change(screen.getByTestId('byo-machine-select'), { target: { value: 'mach-b' } }); - expect(screen.getByTestId('byo-preview')).toHaveTextContent('Appears when the daemon on Studio starts it.'); + expect(preview).not.toHaveTextContent('Appears in the pod once the daemon on Studio starts it.'); }); test('no machines — no card, and the one-paste setup panel shows instead', async () => { diff --git a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx index bf0112dddb..cd644367f2 100644 --- a/frontend/src/v2/__tests__/V2SettingsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2SettingsPage.test.tsx @@ -16,7 +16,9 @@ jest.mock('../../utils/axiosConfig', () => { }); jest.mock('../../components/AppsManagement', () => { - const MockAppsManagement = ({ variant }: { variant?: string }) =>
Connected app controls
; + const MockAppsManagement = ({ variant, showIntegrations }: { variant?: string; showIntegrations?: boolean }) => ( +
Connected app controls
+ ); MockAppsManagement.displayName = 'MockAppsManagement'; return MockAppsManagement; }); @@ -140,6 +142,9 @@ describe('V2SettingsPage', () => { expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('src', '/uploads/lily.png'); expect(screen.getByRole('img', { name: 'lily avatar' })).toHaveAttribute('data-kind', 'human'); expect(screen.getByText('Connected app controls')).toHaveAttribute('data-variant', 'settings'); + expect(screen.getByText('Connected app controls')).toHaveAttribute('data-show-integrations', 'false'); + expect(screen.getByRole('link', { name: /Connectors/ })).toHaveAttribute('href', '/v2/connectors'); + expect(screen.getByText('Developer apps').closest('details')).not.toHaveAttribute('open'); expect(screen.getByRole('button', { name: 'Regenerate' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Upload photo' })).toBeInTheDocument(); expect(screen.getByText('Regenerate for another, or upload a photo.')).toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 87fa690f1d..02d666eefc 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -980,7 +980,9 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(settingsPage).toContain('className="v2-settings__avatar"'); expect(settingsPage).toContain('src={currentUser?.profilePicture || undefined}'); expect(settingsPage).toContain('kind="human"'); - expect(settingsPage).toContain(''); + expect(settingsPage).toContain(''); + expect(settingsPage).toContain('to="/v2/connectors"'); + expect(settingsPage).toContain('Developer apps'); expect(settingsPage).toContain(''); expect(settingsPage).toContain(''); expect(ruleBody(v2, '.v2-settings__avatar')).toContain('width: 64px'); @@ -2873,10 +2875,10 @@ describe('the landing hero demo (TASK-147)', () => { // (5, 6), and the avatar is a 4px square with a cobalt live dot (7). // // Items 1–6 are presence lines. The reason they can be, rather than a cascade -// comparison: `button.v2-byo__mode` and `button.v2-byo__submit` are already -// rooted at `.v2-root`, so the bare reset at `v2.css:201` cannot outrank them -// the way it did the landing pill in TASK-159. That trap is pre-avoided in this -// component, not merely untested here. +// comparison: the mode card owns its button reset locally, while +// `button.v2-byo__submit` is rooted at `.v2-root`, so the bare reset at +// `v2.css:201` cannot outrank them the way it did the landing pill in TASK-159. +// That trap is pre-avoided in this component, not merely untested here. // // Item 7 gets three assertions, because each of its failure modes is invisible // to a presence line: @@ -2957,7 +2959,7 @@ describe('Bring your own agent onto Signal (TASK-166)', () => { expect(hover['border-color']).toBe('var(--v2-ink-hover)'); }); - test('the chosen mode card is a 2px accent border that holds its box (item 2)', () => { + test('the mode cards keep button-specific card styling (item 2)', () => { const base = cssDeclarations(ruleBody(v2, '.v2-root button.v2-byo__mode')); expect(base['border-radius']).toBe('4px'); // The 1px-per-side compensation is derived from the base shorthand, so it is @@ -2969,6 +2971,8 @@ describe('Bring your own agent onto Signal (TASK-166)', () => { ); expect(pressed.border).toBe('2px solid var(--v2-accent)'); expect(pressed.padding).toBe('15px 17px 13px'); + expect(v2).toContain('.v2-root button.v2-byo__mode:hover'); + expect(v2).toContain('.v2-root button.v2-byo__mode:focus-visible'); // (c) the ring belongs to :focus-visible, and a ring here is the defect. expect(pressed['box-shadow']).toBeUndefined(); expect( @@ -3133,9 +3137,10 @@ describe('Bring your own agent onto Signal (TASK-166)', () => { // The negative that says what the fold-in removed: no status colour and no // pill fill anywhere in this block. A future edit that adds one back is the // regression, and a presence check on the new values cannot see it. - const to = v2.indexOf('.v2-byo__preview-note {'); - expect(to).toBeGreaterThan(sectionFrom); - const block = v2.slice(sectionFrom, to); + expect(v2).not.toContain('.v2-byo__preview-note {'); + const blockEnd = v2.indexOf('.v2-byo__result-hero {', sectionFrom); + expect(blockEnd).toBeGreaterThan(sectionFrom); + const block = v2.slice(sectionFrom, blockEnd); ['--v2-success', '--v2-success-text', '--v2-success-soft', '--v2-warning', '--v2-surface-hover'] .forEach((token) => expect(block).not.toContain(token)); diff --git a/frontend/src/v2/__tests__/v2-type-floors.test.ts b/frontend/src/v2/__tests__/v2-type-floors.test.ts index b5b11049f6..c760c82218 100644 --- a/frontend/src/v2/__tests__/v2-type-floors.test.ts +++ b/frontend/src/v2/__tests__/v2-type-floors.test.ts @@ -134,7 +134,6 @@ const ALLOWLIST: string[] = [ // TASK-166 took `.v2-byo__mode-kicker` (10.0px, no family) and // `.v2-byo__preview-label` (11.0px, no family) to mono 500 11px, so both // entries left this burn-down list rather than being updated in place. - '.v2-byo__mode-meta', // 11.0px — no-mono-family // TASK-169 took `.v2-byo__stat-label` (11.0px, no family) to mono 500 11px // with item 2, so its entry left this burn-down list rather than being // updated in place. diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 55f1c5e31f..c77dd179c7 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import V2FeaturePage from './V2FeaturePage'; import V2Avatar from './V2Avatar'; +import V2Hint from './V2Hint'; import { useV2Api } from '../hooks/useV2Api'; import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; @@ -167,6 +168,7 @@ const V2AgentBYO: React.FC = () => { const [pendingReplacement, setPendingReplacement] = useState<{ agentName: string; podId: string } | null>(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [errorField, setErrorField] = useState<'machine' | 'name' | 'pod' | null>(null); const [issued, setIssued] = useState<{ token: string; agentName: string; podId: string; issuedAt: number } | null>(null); const [copied, setCopied] = useState(null); // Which CLI the listener runs. Pinned in the command (`--adapter`), so a Mac @@ -247,9 +249,6 @@ const V2AgentBYO: React.FC = () => { // must not open onto a page that shows no command. Elsewhere the full page // says why and what works instead. const showCompactByo = compactByo && listenerAvailable && !showCompactHosted && !personaCard && mode === 'byo'; - // One lookup, two readers (TASK-163): the preview note and the placement - // payload must name the SAME machine, so it is derived once here rather - // than restated at each call site. const selectedMachineName = machines.find((m) => m.machineId === machineId)?.name || machineId; // Model choice for the on-my-computer path. Adapter and model travel // together: a Codex model slug must never be left behind if the user flips @@ -498,6 +497,7 @@ const V2AgentBYO: React.FC = () => { // here either), and starts the agent — the page watches it come up. const submitMachine = async (cleanName: string) => { if (!machineId) { + setErrorField('machine'); setError(t('agentByo.errors.machineRequired')); return; } @@ -548,13 +548,16 @@ const V2AgentBYO: React.FC = () => { const submit = async () => { setError(null); + setErrorField(null); setPendingReplacement(null); const cleanName = sanitizeAgentName(name); if (!cleanName) { + setErrorField('name'); setError(t('agentByo.errors.nameRequired')); return; } if (!podId) { + setErrorField('pod'); setError(t('agentByo.errors.podRequired')); return; } @@ -758,6 +761,8 @@ const V2AgentBYO: React.FC = () => { } }; + const chooseMode = (nextMode: 'hosted' | 'byo' | 'machine') => setMode(nextMode); + const apiUrl = typeof window !== 'undefined' && /api-dev|api\./.test(window.location.hostname) ? `https://${window.location.hostname.replace(/^app/, 'api')}` : 'https://api.commonly.me'; @@ -916,52 +921,67 @@ const V2AgentBYO: React.FC = () => { <>

{t('agentByo.mode.label')}

- +
+ + + {t('agentByo.mode.hostedMeta', { turns: hosting.caps.turnsPerDay })} + +
{machines.length > 0 && ( +
+ + + {t('agentByo.mode.machineMeta')} + +
+ )} +
- )} - + + {t('agentByo.mode.byoMeta')} + +
)} {mode === 'machine' && ( -