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
98 changes: 87 additions & 11 deletions frontend/src/v2/__tests__/V2PodChatStarter.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @ts-nocheck
import React from 'react';
import { render, screen } from '@testing-library/react';
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2PodChat from '../components/V2PodChat';
import { AuthContext } from '../../context/AuthContext';
Expand All @@ -12,6 +14,12 @@ jest.mock('../../context/SocketContext', () => ({
useSocket: () => ({ socket: null, connected: false }),
}));

jest.mock('../components/V2MessageBubble', () => {
const MockV2MessageBubble = () => <div>Rendered message</div>;
MockV2MessageBubble.displayName = 'MockV2MessageBubble';
return MockV2MessageBubble;
});

// jsdom has no scrollIntoView; the component auto-scrolls on mount.
beforeAll(() => {
Element.prototype.scrollIntoView = jest.fn();
Expand Down Expand Up @@ -59,32 +67,100 @@ const makeDetail = (overrides = {}) => ({
...overrides,
});

const renderChat = (detail) => render(
const renderChat = (detail, props = {}) => render(
<AuthContext.Provider value={authValue}>
<MemoryRouter>
<V2PodChat detail={detail} />
<V2PodChat detail={detail} {...props} />
</MemoryRouter>
</AuthContext.Provider>,
);

describe('V2PodChat starter workspace empty state', () => {
test('solo empty pod shows the getting-started onboarding', () => {
const makeAgentRoom = (overrides = {}) => makeDetail({
pod: { _id: 'agent-room-1', name: 'Aria', type: 'agent-room' },
members: [
{ _id: 'u1', username: 'solo-user', isBot: false },
{ _id: 'agent-1', username: 'openclaw-aria', isBot: true },
],
agents: [{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' }],
...overrides,
});

describe('V2PodChat teaching empty states', () => {
test('regular empty pods teach visible membership and @-mentions', () => {
renderChat(makeDetail());

expect(screen.getByText(/welcome to your workspace/i)).toBeInTheDocument();
expect(screen.getByText(/connect your agent \(claude code, cursor, codex\)/i)).toBeInTheDocument();
expect(screen.getByText(/see your team/i)).toBeInTheDocument();
expect(screen.getByText('This pod is quiet')).toBeInTheDocument();
expect(screen.getByText(/use @ to mention an agent or teammate/i)).toBeInTheDocument();
expect(screen.getByText(/everyone in the member list can see and reply/i)).toBeInTheDocument();
});

test('multi-member pod keeps the generic empty state', () => {
test('multi-member regular pods use the same teaching state', () => {
renderChat(makeDetail({
members: [
{ _id: 'u1', username: 'solo-user', isBot: false },
{ _id: 'u2', username: 'teammate', isBot: false },
],
}));

expect(screen.getByText(/talk to your team/i)).toBeInTheDocument();
expect(screen.queryByText(/welcome to your workspace/i)).not.toBeInTheDocument();
expect(screen.getByText('This pod is quiet')).toBeInTheDocument();
});

test('keeps the agent-to-agent empty state unchanged', () => {
renderChat(makeDetail({
pod: { _id: 'agent-dm-1', name: 'Aria and Pixel', type: 'agent-dm' },
members: [
{ _id: 'agent-1', username: 'Aria', isBot: true },
{ _id: 'agent-2', username: 'Pixel', isBot: true },
],
}));

expect(screen.getByText("Aria and Pixel haven't talked yet")).toBeInTheDocument();
expect(screen.getByText("They'll DM each other when one of them needs the other's help.")).toBeInTheDocument();
});
});

describe('V2PodChat starter prompts', () => {
test('render for an empty agent room after first-run and never auto-send', async () => {
const detail = makeAgentRoom();
renderChat(detail);

const prompt = screen.getByRole('button', {
name: 'Introduce yourself — what are you best at?',
});
expect(screen.getByRole('group', { name: 'Conversation starters' })).toBeInTheDocument();
expect(screen.getByRole('button', {
name: "Here's what I'm working on — where can you help?",
})).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'What should I ask you first?' })).toBeInTheDocument();

fireEvent.click(prompt);

const composer = screen.getByPlaceholderText('Message Aria…');
expect(composer).toHaveValue('Introduce yourself — what are you best at?');
await waitFor(() => expect(composer).toHaveFocus());
expect(detail.sendMessage).not.toHaveBeenCalled();
expect(screen.queryByRole('group', { name: 'Conversation starters' })).not.toBeInTheDocument();
});

test('also render for a writable human-agent DM', () => {
renderChat(makeAgentRoom({
pod: { _id: 'agent-dm-1', name: 'Aria DM', type: 'agent-dm' },
}));

expect(screen.getByRole('group', { name: 'Conversation starters' })).toBeInTheDocument();
});

test('hide after a message exists or while the first-run hero is visible', () => {
const withMessage = renderChat(makeAgentRoom({
messages: [{ id: 'm1', content: 'Hello', user: { username: 'Aria' } }],
}));
expect(screen.queryByRole('group', { name: 'Conversation starters' })).not.toBeInTheDocument();
withMessage.unmount();

renderChat(makeAgentRoom(), {
firstRunVisible: true,
firstRunHero: <div>First-run guide</div>,
});
expect(screen.queryByRole('group', { name: 'Conversation starters' })).not.toBeInTheDocument();
});
});
111 changes: 111 additions & 0 deletions frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// @ts-nocheck
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import V2PodsSidebar from '../components/V2PodsSidebar';

const COMMUNITY_POD_ID = 'community-pod';
const mockJoinPod = jest.fn();
const mockSeedFromExisting = jest.fn();

jest.mock('../hooks/useV2Pods', () => ({
useV2Pods: () => ({
pods: [],
loading: false,
error: null,
createPod: jest.fn(),
patchLastMessage: jest.fn(),
}),
}));

jest.mock('../hooks/useV2Pinned', () => ({
useV2Pinned: () => ({
pinned: new Set(),
toggle: jest.fn(),
isPinned: () => false,
}),
}));

jest.mock('../hooks/useV2Unread', () => ({
useV2Unread: () => ({
isUnread: () => false,
bumpLatest: jest.fn(),
seedFromExisting: mockSeedFromExisting,
}),
}));

jest.mock('../../context/SocketContext', () => ({
useSocket: () => ({ socket: null, connected: false, joinPod: mockJoinPod }),
}));

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'human-1', username: 'new-human' } }),
}));

const CurrentPath = () => <div data-testid="current-path">{useLocation().pathname}</div>;

const makePod = (id: string, memberIds: string[]) => ({
_id: id,
name: id === COMMUNITY_POD_ID ? 'Commonly HQ' : 'My Workspace',
type: 'team',
members: memberIds.map((_id) => ({ _id, username: _id, isBot: false })),
createdAt: '2026-07-21T12:00:00.000Z',
updatedAt: '2026-07-21T12:00:00.000Z',
});

const renderSidebar = (pods) => {
const podsState = {
pods,
loading: false,
error: null,
createPod: jest.fn(),
patchLastMessage: jest.fn(),
};
return render(
<MemoryRouter initialEntries={['/v2/pods/workspace']}>
<V2PodsSidebar selectedPodId="workspace" podsState={podsState} />
<CurrentPath />
</MemoryRouter>,
);
};

describe('V2PodsSidebar Community offer', () => {
const originalCommunityPodId = process.env.REACT_APP_COMMUNITY_POD_ID;

beforeEach(() => {
jest.clearAllMocks();
process.env.REACT_APP_COMMUNITY_POD_ID = COMMUNITY_POD_ID;
});

afterAll(() => {
if (originalCommunityPodId === undefined) {
delete process.env.REACT_APP_COMMUNITY_POD_ID;
} else {
process.env.REACT_APP_COMMUNITY_POD_ID = originalCommunityPodId;
}
});

test('shows for a configured Community pod the human has not joined and navigates to the redirect', () => {
renderSidebar([makePod('workspace', ['human-1'])]);

expect(screen.getByText('Meet the builders and their agents.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Join HQ' }));
expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/community');
});

test('stays hidden once the Community pod is in memberPodIds', () => {
renderSidebar([
makePod('workspace', ['human-1']),
makePod(COMMUNITY_POD_ID, ['human-1']),
]);

expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument();
});

test('stays hidden for self-hosted instances without Community config', () => {
delete process.env.REACT_APP_COMMUNITY_POD_ID;
renderSidebar([makePod('workspace', ['human-1'])]);

expect(screen.queryByRole('button', { name: 'Join HQ' })).not.toBeInTheDocument();
});
});
19 changes: 19 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,23 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(rule).toContain('background: var(--v2-accent)');
expect(rule).toContain('color: #fff');
});

test('the Community offer stays visible below the independently scrolling pod list', () => {
// The list owns overflow-y; the offer is its flex sibling. Preventing the
// card from shrinking is what keeps the Join HQ action reachable when the
// sidebar contains many pods.
expect(ruleBody(v2, '.v2-pods__community')).toContain('flex-shrink: 0');
});

test('starter prompts wrap within the mobile chat pane', () => {
// At 390px the rail leaves a narrow main pane. Both the row and each chip
// need explicit shrink/wrap rules or the longest prompt creates horizontal
// overflow and pushes the composer action off-screen.
const row = ruleBody(v2, '.v2-chat__starter-prompts');
const chip = ruleBody(v2, '.v2-root button.v2-chat__starter-prompt');
expect(row).toContain('flex-wrap: wrap');
expect(row).toContain('max-width: 100%');
expect(chip).toContain('max-width: 100%');
expect(chip).toContain('white-space: normal');
});
});
Loading
Loading