Skip to content
Open
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
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@
"unavailable": "Expiry unavailable",
"expiresOn": "Expires {{date}}"
},
"agentHint": "Pick an agent from the catalog to install into this pod.",
"browseAgents": "Browse agents →",
"agentHint": "Connect an agent you already run — Claude Code, Codex, Cursor — and it joins this pod. Takes about two minutes.",
"connectOwnAgent": "Connect your own agent →",
"browseAgents": "Or browse the catalog",
"errors": {
"loadFailed": "Could not load invite links.",
"generateFailed": "Could not generate invite.",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@
"unavailable": "无法查看有效期",
"expiresOn": "{{date}} 到期"
},
"agentHint": "从目录中选择要安装到这个 Pod 的智能体。",
"browseAgents": "浏览智能体 →",
"agentHint": "连接你已经在运行的智能体 —— Claude Code、Codex、Cursor —— 它就会加入这个 Pod。大约需要两分钟。",
"connectOwnAgent": "连接你自己的智能体 →",
"browseAgents": "或浏览目录",
"errors": {
"loadFailed": "邀请链接加载失败。",
"generateFailed": "邀请链接生成失败。",
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/v2/__tests__/V2InviteModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ describe('V2InviteModal', () => {
renderModal('agent');

expect(screen.getByRole('tab', { name: 'Add agent' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('button', { name: 'Browse agents →' })).toBeInTheDocument();
// The primary action is connect-your-own, not the v1 catalog. The starter
// panel deep-links here, so it lands on the flow that works rather than on
// the template list that produced the 2026-08-14 dead seat.
expect(screen.getByRole('button', { name: 'Connect your own agent →' })).toBeInTheDocument();
// The catalog is demoted, not removed.
expect(screen.getByRole('button', { name: 'Or browse the catalog' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Generate invite link' })).not.toBeInTheDocument();
});
});
83 changes: 83 additions & 0 deletions frontend/src/v2/__tests__/V2InviteModalAgentPath.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2InviteModal from '../components/V2InviteModal';
import en from '../../i18n/locales/en.json';
import zhCN from '../../i18n/locales/zh-CN.json';

/**
* The in-pod "add an agent" path must lead to the flow that works.
*
* It used to offer exactly one button, into `/v2/agents/browse` — the v1
* AgentsHub, whose catalog still serves 21 internal and smoke-test rows
* (`smoke-claude`, `demo-target`, `pod-architect`, `cl-critic`, …) beside real
* entries. On 2026-08-14 a real user took that door, installed the
* `claude-code` template, never started a local session, asked it the same
* question three times and got silence. They had a working Scout in the next
* room the entire time.
*
* So: connect-your-own leads, the catalog stays reachable underneath.
*/

const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});

jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => ({
get: jest.fn().mockResolvedValue({ invites: [] }),
post: jest.fn(), patch: jest.fn(), del: jest.fn(),
}),
}));

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'u1', username: 'sam' } }),
}));

const POD = '6a7d154b0ec237d4b15dd28b';

const open = () => render(
<MemoryRouter>
<V2InviteModal open podId={POD} podName="My Workspace" initialTab="agent" onClose={jest.fn()} />
</MemoryRouter>,
);

describe('the in-pod add-agent path', () => {
beforeEach(() => jest.clearAllMocks());

test('the primary action goes to BYO connect, with the pod prefilled', () => {
open();

const cta = screen.queryByText(en.inviteModal.connectOwnAgent);
expect(cta).toBeTruthy();
fireEvent.click(cta as HTMLElement);

// `?pod=` — V2AgentBYO reads `pod`, NOT `podId`. Getting this wrong sends
// the user to an unprefilled form, which is how a two-minute flow becomes
// a dead end.
expect(mockNavigate).toHaveBeenCalledWith(`/v2/agents/byo?pod=${POD}`);
});

test('the v1 catalog is demoted, not the only door', () => {
open();

const browse = screen.queryByText(en.inviteModal.browseAgents);
expect(browse).toBeTruthy();
fireEvent.click(browse as HTMLElement);

expect(mockNavigate).toHaveBeenCalledWith(`/v2/agents/browse?podId=${POD}`);
});
});

describe('both locales carry the new primary action', () => {
test.each([['en', en as any], ['zh-CN', zhCN as any]])('%s', (_l, bundle) => {
// Two of the users this change is for wrote Chinese.
expect(bundle.inviteModal.connectOwnAgent).toBeTruthy();
expect(bundle.inviteModal.browseAgents).toBeTruthy();
// The hint must describe connecting your own agent, not picking from a
// catalog — the sentence is what sets the expectation before the click.
expect(bundle.inviteModal.agentHint).not.toMatch(/catalog|目录/);
});
});
22 changes: 22 additions & 0 deletions frontend/src/v2/components/V2InviteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,34 @@ const V2InviteModal: React.FC<V2InviteModalProps> = ({
)}
{tab === 'agent' && (
<>
{/* The primary action is CONNECT YOUR OWN, not the catalog.
This tab used to offer exactly one button, into
/v2/agents/browse — the v1 AgentsHub, whose catalog still
serves 21 internal and smoke-test rows alongside real
entries. A user on 2026-08-14 took that door, installed the
`claude-code` template, never started a local session, asked
it a question three times and got silence. Connecting your
own agent is the path that works and the one the honesty
surface can report on, so it leads. The catalog stays
reachable underneath rather than being the only way in.

Note `?pod=` — V2AgentBYO reads `pod`, not `podId`. */}
<p className="v2-modal__hint">
{t('inviteModal.agentHint')}
</p>
<button
type="button"
className="v2-invite-card__cta"
onClick={() => {
onClose();
navigate(`/v2/agents/byo?pod=${podId}`);
}}
>
{t('inviteModal.connectOwnAgent')}
</button>
<button
type="button"
className="v2-modal__link-btn"
onClick={() => {
onClose();
navigate(`/v2/agents/browse?podId=${podId}`);
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
Expand Up @@ -4138,6 +4138,26 @@
filter: brightness(1.05);
}

/* Secondary, text-weight action under a primary CTA. Carries the
`.v2-root button.` prefix deliberately: the global button reset is 0-2-1 and
beats a bare class, which has already shipped once as a styled action
rendering like plain prose (#870, guarded in v2-layout-invariants). */
.v2-root button.v2-modal__link-btn {
width: 100%;
margin-top: 6px;
padding: 4px 0;
border: none;
background: none;
color: var(--v2-text-secondary);
font-size: 12.5px;
cursor: pointer;
text-decoration: underline;
}

.v2-root button.v2-modal__link-btn:hover {
color: var(--v2-text-primary);
}

.v2-root button.v2-invite-card__cta:disabled,
.v2-invite-card__cta:disabled {
opacity: 0.6;
Expand Down
Loading