From d1b4e198d9fcbde3b18949d85218f64c32f94b0b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:35:31 -0700 Subject: [PATCH] feat: guide empty first-run spaces --- .../v2/__tests__/V2PodChatStarter.test.tsx | 98 ++++++++++++++-- .../V2PodsSidebar.community.test.tsx | 111 ++++++++++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 19 +++ frontend/src/v2/components/V2PodChat.tsx | 104 +++++++--------- frontend/src/v2/components/V2PodsSidebar.tsx | 25 ++++ frontend/src/v2/v2.css | 102 +++++++++++----- 6 files changed, 354 insertions(+), 105 deletions(-) create mode 100644 frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx diff --git a/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx b/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx index 6837f314..0ae5a05c 100644 --- a/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2PodChatStarter.test.tsx @@ -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'; @@ -12,6 +14,12 @@ jest.mock('../../context/SocketContext', () => ({ useSocket: () => ({ socket: null, connected: false }), })); +jest.mock('../components/V2MessageBubble', () => { + const MockV2MessageBubble = () =>
Rendered message
; + MockV2MessageBubble.displayName = 'MockV2MessageBubble'; + return MockV2MessageBubble; +}); + // jsdom has no scrollIntoView; the component auto-scrolls on mount. beforeAll(() => { Element.prototype.scrollIntoView = jest.fn(); @@ -59,24 +67,34 @@ const makeDetail = (overrides = {}) => ({ ...overrides, }); -const renderChat = (detail) => render( +const renderChat = (detail, props = {}) => render( - + , ); -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 }, @@ -84,7 +102,65 @@ describe('V2PodChat starter workspace empty state', () => { ], })); - 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:
First-run guide
, + }); + expect(screen.queryByRole('group', { name: 'Conversation starters' })).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx new file mode 100644 index 00000000..b248844f --- /dev/null +++ b/frontend/src/v2/__tests__/V2PodsSidebar.community.test.tsx @@ -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 = () =>
{useLocation().pathname}
; + +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( + + + + , + ); +}; + +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(); + }); +}); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 06024233..8fc4ee4e 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -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'); + }); }); diff --git a/frontend/src/v2/components/V2PodChat.tsx b/frontend/src/v2/components/V2PodChat.tsx index 31abf207..26b89418 100644 --- a/frontend/src/v2/components/V2PodChat.tsx +++ b/frontend/src/v2/components/V2PodChat.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import V2Avatar from './V2Avatar'; import V2MessageBubble from './V2MessageBubble'; import { @@ -14,6 +13,12 @@ import { initialsFor } from '../utils/avatars'; const PLAN_MODE_KEY = 'v2.podMode'; +const STARTER_PROMPTS = [ + 'Introduce yourself — what are you best at?', + "Here's what I'm working on — where can you help?", + 'What should I ask you first?', +] as const; + type PodMode = 'plan' | 'execute'; const podMarkFor = (name: string, type?: string): string => ( @@ -150,7 +155,6 @@ const Icon = ({ d }: { d: string }) => ( const V2PodChat: React.FC = ({ detail, firstRunHero, firstRunVisible = false, inspectorCollapsed, onToggleInspector, onOpenMember, onOpenInvite, onOpenFile, onOpenMobileNav }) => { const { pod, members, messages, agents, sendMessage, loading, error } = detail; - const navigate = useNavigate(); const api = useV2Api(); const { socket, connected } = useSocket(); const { currentUser } = useAuth(); @@ -560,6 +564,17 @@ const V2PodChat: React.FC = ({ detail, firstRunHero, firstRunVis } }; + const handleStarterPrompt = (prompt: string) => { + setDraft(prompt); + setMentionOpen(false); + requestAnimationFrame(() => { + const input = composerInputRef.current; + if (!input) return; + input.focus(); + input.setSelectionRange(prompt.length, prompt.length); + }); + }; + // Composer attach: handles both images (sends as standalone image message, // legacy v2 behavior) and other file kinds (PDF / md / txt / csv / json, // inserts an [[upload:fileName|originalName|size|kind]] directive into the @@ -728,39 +743,16 @@ const V2PodChat: React.FC = ({ detail, firstRunHero, firstRunVis
They'll DM each other when one of them needs the other's help.
) : isAgentRoom && botMembers.length === 1 ? ( - // Sprint B4: first-message coaching for the 60s "install - // your first agent → talk to it" wedge. Shows the agent's - // display name + 3 generic suggestion chips that pre-fill - // the composer. Pod-summarizer agent-rooms ride this same - // branch — generic chips degrade gracefully for those. (() => { const rawUsername = botMembers[0]?.username || ''; const agentName = agentDisplayNames.get(rawUsername.toLowerCase()) || rawUsername || 'agent'; - const suggestions = [ - `Hey ${agentName}, what can you do for me?`, - 'What are you working on right now?', - 'Help me get started — what should I try first?', - ]; return ( <>
Say hi to {agentName}
- This is your 1:1 with {agentName}. Try one of these, or write your own: -
-
- {suggestions.map((s) => ( - - ))} + This is your private 1:1. Choose a prompt below, or write your own.
); @@ -770,47 +762,13 @@ const V2PodChat: React.FC = ({ detail, firstRunHero, firstRunVis
No messages yet
This is a private 1:1 conversation. Say hello to get started.
- ) : (members || []).length <= 1 && botMembers.length === 0 ? ( - // Starter workspace: the user is alone in an empty pod - // (the default "My Workspace" every signup gets, or any - // solo pod). Scripted onboarding — the checklist tasks - // seeded at signup live on the board; this points the - // way to the first one. + ) : ( <> -
Welcome to your workspace
+
This pod is quiet
- This is where you and your agents work together. Agents you - connect keep their memory here — everything they learn stays - with them. Start with the checklist on your task board, or - jump straight in: -
-
- - {/* Points at Your Team (not the marketplace) while the - marketplace sits behind its coming-soon wall — a - fresh user's second CTA must not be a dead end. */} - + Use @ to mention an agent or teammate—everyone in the member list can see and reply.
- ) : ( - <> -
Talk to your team
-
Type a message, or @-mention an agent to direct your first task.
- )} )} @@ -830,6 +788,26 @@ const V2PodChat: React.FC = ({ detail, firstRunHero, firstRunVis + {!isReadOnly + && (isAgentRoom || isAgentDm) + && !loading + && messages.length === 0 + && !firstRunVisible + && !draft.trim() && ( +
+ {STARTER_PROMPTS.map((prompt) => ( + + ))} +
+ )} + {isReadOnly ? (
))}
+ + {showCommunityOffer && ( +
+
+ Join Commonly HQ +
+
+ Meet the builders and their agents. +
+ +
+ )} ); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index bdb37393..c71cde91 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -974,6 +974,45 @@ font-size: 13px; } +.v2-pods__community { + flex-shrink: 0; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); +} + +.v2-pods__community-title { + color: var(--v2-text-primary); + font-size: 13px; + font-weight: 700; +} + +.v2-pods__community-copy { + margin-top: 3px; + color: var(--v2-text-tertiary); + font-size: 12px; + line-height: 1.4; +} + +.v2-root button.v2-pods__community-button { + width: 100%; + min-height: 34px; + margin-top: 10px; + border-radius: var(--v2-radius-sm); + background: var(--v2-accent-soft); + color: var(--v2-accent-text); + font-size: 12px; + font-weight: 700; + transition: background 80ms ease, color 80ms ease; +} + +.v2-root button.v2-pods__community-button:hover { + background: var(--v2-accent); + color: var(--v2-surface); +} + /* ---------- Pod chat (main) ---------- */ .v2-chat { @@ -1478,6 +1517,38 @@ flex-shrink: 0; } +.v2-chat__starter-prompts { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-width: 100%; + padding: 10px 24px 0; + background: var(--v2-surface); + flex-shrink: 0; +} + +.v2-root button.v2-chat__starter-prompt { + min-width: 0; + max-width: 100%; + padding: 7px 11px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-pill); + background: var(--v2-surface); + color: var(--v2-text-secondary); + font-size: 12px; + font-weight: 600; + line-height: 1.35; + text-align: left; + white-space: normal; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; +} + +.v2-root button.v2-chat__starter-prompt:hover { + border-color: var(--v2-border-strong); + background: var(--v2-accent-soft); + color: var(--v2-accent-text); +} + .v2-chat__composer-input-wrap { position: relative; display: flex; @@ -4433,37 +4504,6 @@ max-width: 360px; } -/* Sprint B4 — first-message coaching chips in agent-room empty state. - * Clickable suggestions that pre-fill the composer; same border-not-shadow - * aesthetic as the rest of the v2 inspector cards. Wraps so the row fits - * narrow side-panel layouts. */ -.v2-empty__chips { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - margin-top: 12px; - max-width: 480px; -} - -.v2-root button.v2-empty__chip { - padding: 8px 14px; - border-radius: 999px; - border: 1px solid var(--v2-border); - background: var(--v2-surface); - color: var(--v2-text-secondary); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; -} - -.v2-root button.v2-empty__chip:hover { - background: var(--v2-surface-hover); - border-color: var(--v2-accent-subtle); - color: var(--v2-text-primary); -} - .v2-spinner { width: 18px; height: 18px;