diff --git a/backend/__tests__/unit/routes/podInvites.resolve.test.js b/backend/__tests__/unit/routes/podInvites.resolve.test.js new file mode 100644 index 0000000000..2b4ba8b898 --- /dev/null +++ b/backend/__tests__/unit/routes/podInvites.resolve.test.js @@ -0,0 +1,54 @@ +const request = require('supertest'); +const express = require('express'); + +jest.mock('../../../middleware/auth', () => (req, res, next) => { + req.userId = 'user1'; + next(); +}); + +const mockFindOne = jest.fn(); +jest.mock('../../../models/PodInvite', () => ({ + PodInvite: { findOne: (...args) => mockFindOne(...args), create: jest.fn() }, +})); + +// eslint-disable-next-line import/no-unresolved, import/extensions +const Pod = require('../../../models/Pod'); +jest.mock('../../../models/Pod'); + +// eslint-disable-next-line import/no-unresolved, import/extensions +const routes = require('../../../routes/podInvites'); + +describe('GET /api/invites/:token (authenticated)', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use('/api', routes); + mockFindOne.mockResolvedValue({ + token: 'tok123', podId: 'p1', expiresAt: null, isUsable: () => true, + }); + }); + + const resolveForOwner = async (createdBy) => { + const select = jest.fn().mockReturnValue({ lean: jest.fn().mockResolvedValue({ + _id: 'p1', name: 'My Workspace', description: 'Your private workspace', + type: 'chat', members: ['user1'], createdBy, + }) }); + Pod.findById.mockReturnValue({ select }); + const response = await request(app).get('/api/invites/tok123').expect(200); + expect(select).toHaveBeenCalledWith(expect.stringContaining('createdBy')); + return response.body; + }; + + it('marks the creator as owner without exposing their ID in the invite payload', async () => { + const body = await resolveForOwner('user1'); + expect(body.isOwner).toBe(true); + expect(body.pod).not.toHaveProperty('createdBy'); + }); + + it('marks another member as a guest of the workspace', async () => { + const body = await resolveForOwner('other-user'); + expect(body.isOwner).toBe(false); + }); +}); diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts index 3f296c54d9..b87f15557d 100644 --- a/backend/routes/podInvites.ts +++ b/backend/routes/podInvites.ts @@ -200,7 +200,7 @@ router.get('/invites/:token', inviteReadRateLimit, auth, async (req: any, res: a return res.status(404).json({ msg: 'Invite invalid or expired' }); } const pod = await Pod.findById(invite.podId) - .select('_id name description type members createdAt') + .select('_id name description type members createdBy createdAt') .lean(); if (!pod) return res.status(404).json({ msg: 'Pod no longer exists' }); const alreadyMember = (pod.members || []).some( @@ -216,6 +216,9 @@ router.get('/invites/:token', inviteReadRateLimit, auth, async (req: any, res: a memberCount: (pod.members || []).length, }, alreadyMember, + // The invite page must distinguish the owner's own workspace from one + // shared by someone else. Keep the owner ID private; send only this fact. + isOwner: String(pod.createdBy || '') === String(userId), expiresAt: invite.expiresAt, }); } catch (err: any) { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 618dca6c2e..f8110e1685 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -323,6 +323,7 @@ "fallbackPod": "a pod", "untitledPod": "Untitled pod", "invitedTo": "You've been invited to {{podName}}", + "sharedWorkspaceDescription": "A private workspace shared with you", "previewMeta_one": "{{formattedCount}} member · humans and agents working together on Commonly", "previewMeta_other": "{{formattedCount}} members · humans and agents working together on Commonly", "memberCount_one": "{{formattedCount}} member", @@ -339,6 +340,10 @@ "joinFailed": "Could not join — try again." } }, + "podNames": { + "otherWorkspace": "{{name}} ({{owner}})", + "shared": "shared" + }, "firstRun": { "eyebrow": "Your first five minutes", "title": "Bring your agent into your pod", @@ -1352,8 +1357,9 @@ "chooseFirst": "Choose where it runs first" }, "footnote": { - "preferCli": "Prefer the CLI?", - "then": "then", + "cliRoute": "Prefer the CLI? Choose “{{byo}}”; after install, the next step shows the listener command that answers @mentions.", + "cliRouteClaudeUnavailable": "Prefer the CLI? Choose “{{byo}}”; after install, connect Claude Code to Commonly from your own sessions. To answer @mentions, run the listener on a supported computer or choose “{{hosted}}”.", + "cliRouteCodexUnavailable": "Prefer the CLI? Choose “{{byo}}”; after install, the next step explains how to run the listener on a Mac or Linux computer. To answer @mentions from Windows, choose “{{hosted}}”.", "notSure": "Not sure which path?", "mcpVsCli": "MCP vs CLI vs SDK", "fullWalkthrough": "full walkthrough" diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 45bb221bfa..a33f2f532f 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -323,6 +323,7 @@ "fallbackPod": "一个 Pod", "untitledPod": "未命名 Pod", "invitedTo": "你受邀加入 {{podName}}", + "sharedWorkspaceDescription": "与你共享的私人工作空间", "signUpToJoin": "注册并加入", "alreadyHaveAccount": "已有账号?登录", "goToPods": "前往你的 Pod", @@ -339,6 +340,10 @@ "previewMeta_one": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作", "previewMeta_other": "{{formattedCount}}位成员 · 人与智能体在 Commonly 中共同协作" }, + "podNames": { + "otherWorkspace": "{{name}}({{owner}})", + "shared": "他人共享" + }, "firstRun": { "eyebrow": "最初的五分钟", "title": "把你的智能体请进你的 Pod", @@ -1412,8 +1417,9 @@ "chooseFirst": "先选择运行位置" }, "footnote": { - "preferCli": "更喜欢用 CLI?", - "then": "然后", + "cliRoute": "更喜欢用 CLI?选择「{{byo}}」;安装后下一步会显示回复 @提及的监听命令。", + "cliRouteClaudeUnavailable": "更喜欢用 CLI?选择「{{byo}}」;安装后可以在自己的 Claude Code 会话中连接 Commonly。要回复 @提及,请在支持的电脑上运行监听命令,或选择「{{hosted}}」。", + "cliRouteCodexUnavailable": "更喜欢用 CLI?选择「{{byo}}」;安装后下一步会说明如何在 Mac 或 Linux 电脑上运行监听命令。要从 Windows 回复 @提及,请选择「{{hosted}}」。", "notSure": "不确定用哪条路径?", "mcpVsCli": "MCP、CLI 与 SDK 对比", "fullWalkthrough": "完整教程" diff --git a/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx index 6c64c12dc4..fdc9ddabd2 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOFootnote.test.tsx @@ -1,10 +1,8 @@ // @ts-nocheck -// The BYO page's footnote is the first command a stranger copies, and the docs -// (#2097) now send them here first. `agent init` has `--language` as a -// requiredOption, so a footnote without it hands out a command that exits 1. -// Pinned after a real user hit exactly that (#2095, 2026-10-09). +// The CLI footnote must describe the connection available after install. +// `agent init` scaffolds a Python webhook and does not connect Claude/Codex. import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; @@ -42,7 +40,7 @@ const authValue = { beforeEach(() => { axios.get.mockImplementation((url) => { if (url.startsWith('/api/hosted/availability')) { - return Promise.resolve({ data: { configured: false, caps: { agentsPerUser: 1, turnsPerDay: 200 } } }); + return Promise.resolve({ data: { configured: true, caps: { agentsPerUser: 1, turnsPerDay: 200 } } }); } if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Workspace', type: 'chat', createdBy: { _id: 'u1' } }] }); if (url === '/api/machines') return Promise.resolve({ data: { machines: [], offlineAfterMs: 90000 } }); @@ -59,10 +57,47 @@ const renderPage = () => render( ); describe('V2AgentBYO footnote', () => { - test('the CLI init command it shows carries every requiredOption of `agent init`', async () => { + const modeTitle = (testId) => screen.getByTestId(testId).querySelector('.v2-byo__mode-title').textContent; + const realUserAgent = window.navigator.userAgent; + const setUserAgent = (value) => Object.defineProperty(window.navigator, 'userAgent', { value, configurable: true }); + afterEach(() => { + setUserAgent(realUserAgent); + window.history.replaceState({}, '', '/'); + }); + + test('names the runtime mode leading to the listener, even from Run it here', async () => { + setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); + renderPage(); + const footnote = await screen.findByText(/Prefer the CLI\?/); + await screen.findByTestId('byo-mode-byo'); + expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”; after install`); + expect(footnote).toHaveTextContent('the next step shows the listener command'); + expect(footnote).not.toHaveTextContent('commonly agent init'); + + fireEvent.click(await screen.findByTestId('byo-mode-hosted')); + expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”; after install`); + }); + + test('on Windows, Claude Code guidance names the MCP path without promising a listener command', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + renderPage(); + const footnote = await screen.findByText(/Prefer the CLI\?/); + await screen.findByTestId('byo-mode-byo'); + expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”`); + expect(footnote).toHaveTextContent('connect Claude Code to Commonly from your own sessions'); + expect(footnote).toHaveTextContent(`run the listener on a supported computer or choose “${modeTitle('byo-mode-hosted')}”`); + expect(footnote).not.toHaveTextContent('the next step shows the listener command'); + }); + + test('on Windows, Codex guidance points to Mac/Linux or hosting', async () => { + setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + window.history.replaceState({}, '', '/?adapter=codex'); renderPage(); - const code = await screen.findByText(/^commonly agent init /); - // Same order the CLI declares them: --language, --name, --pod. - expect(code.textContent).toBe('commonly agent init --language python --name --pod '); + const footnote = await screen.findByText(/Prefer the CLI\?/); + await screen.findByTestId('byo-mode-byo'); + expect(footnote).toHaveTextContent(`Choose “${modeTitle('byo-mode-byo')}”`); + expect(footnote).toHaveTextContent('run the listener on a Mac or Linux computer'); + expect(footnote).toHaveTextContent(`To answer @mentions from Windows, choose “${modeTitle('byo-mode-hosted')}”`); + expect(footnote).not.toHaveTextContent('the next step shows the listener command'); }); }); diff --git a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx index a2951a4d7f..8a6443fa95 100644 --- a/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx +++ b/frontend/src/v2/__tests__/V2AgentBYOPodPicker.test.tsx @@ -4,12 +4,14 @@ // /api/pods, and the old `installablePods[0]` default silently attached // personal agents to the public room — a prompt-injection surface running with // the owner's own tokens. The picker must (a) never offer a publicRead pod, and -// (b) default to the user's OWN workspace, never the most-active pod. +// (b) prefer the pod this person opened over their signup workspace, never +// the most-active pod. import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2AgentBYO from '../components/V2AgentBYO'; import { AuthContext } from '../../context/AuthContext'; +import { POD_VISITS_KEY } from '../lib/podRecency'; // This suite owns the BYO request sequence, not the rail's attention query. jest.mock('../components/V2NavRail', () => () => null); @@ -34,7 +36,11 @@ const authValue = { const renderPage = () => render( , ); -afterEach(() => jest.clearAllMocks()); +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, '', '/'); +}); describe('V2AgentBYO pod picker safety (HQ funnel incident 2026-07-24)', () => { test('excludes publicRead pods and defaults to the user\'s own workspace', async () => { @@ -61,4 +67,29 @@ describe('V2AgentBYO pod picker safety (HQ funnel incident 2026-07-24)', () => { expect(screen.getByRole('combobox').value).toBe('team'); expect(screen.queryByText('Commonly HQ (chat)')).not.toBeInTheDocument(); }); + + test('defaults to the last opened team pod instead of the user\'s workspace', async () => { + localStorage.setItem(POD_VISITS_KEY, JSON.stringify({ ws: 10, team: 20, hq: 30 })); + axios.get.mockResolvedValueOnce({ data: [ + { _id: 'hq', name: 'Commonly HQ', type: 'chat', publicRead: true }, + { _id: 'ws', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1' } }, + { _id: 'team', name: 'Team Project', type: 'team', createdBy: { _id: 'u2' } }, + ] }); + renderPage(); + await waitFor(() => expect(screen.getByRole('combobox').value).toBe('team')); + expect(screen.queryByText('Commonly HQ (chat)')).not.toBeInTheDocument(); + }); + + test('a valid deep link wins over visits, and other My Workspace pods name their owner', async () => { + localStorage.setItem(POD_VISITS_KEY, JSON.stringify({ aaa: 30 })); + window.history.replaceState({}, '', '/v2/agents/byo?pod=bbb'); + axios.get.mockResolvedValueOnce({ data: [ + { _id: 'aaa', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1', username: 'sam' } }, + { _id: 'bbb', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u2', username: 'alex' } }, + ] }); + renderPage(); + await waitFor(() => expect(screen.getByRole('combobox').value).toBe('bbb')); + expect(screen.getByText('My Workspace (chat)')).toBeInTheDocument(); + expect(screen.getByText('My Workspace (alex) (chat)')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/V2InviteRedeem.test.tsx b/frontend/src/v2/__tests__/V2InviteRedeem.test.tsx index b4153782d5..67727ca887 100644 --- a/frontend/src/v2/__tests__/V2InviteRedeem.test.tsx +++ b/frontend/src/v2/__tests__/V2InviteRedeem.test.tsx @@ -66,4 +66,36 @@ describe('V2InviteRedeem', () => { expect(screen.getByText("This is a private 1:1 conversation — it can't be joined with an invite link.")).toBeInTheDocument(); }); }); + + test('does not call someone else\'s default workspace your private workspace', async () => { + mockApi.get.mockResolvedValueOnce({ + token: 'a'.repeat(32), + pod: { _id: 'other-workspace', name: 'My Workspace', description: 'Your private workspace', type: 'chat', memberCount: 2 }, + alreadyMember: false, + isOwner: false, + }); + render( + + } /> + , + ); + expect(await screen.findByText('A private workspace shared with you')).toBeInTheDocument(); + expect(screen.queryByText('Your private workspace')).not.toBeInTheDocument(); + }); + + test('keeps the owner\'s private-workspace description on their own invite', async () => { + mockApi.get.mockResolvedValueOnce({ + token: 'a'.repeat(32), + pod: { _id: 'own-workspace', name: 'My Workspace', description: 'Your private workspace', type: 'chat', memberCount: 1 }, + alreadyMember: true, + isOwner: true, + }); + render( + + } /> + , + ); + expect(await screen.findByText('Your private workspace')).toBeInTheDocument(); + expect(screen.queryByText('A private workspace shared with you')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx index b48f924eea..b1c7966c77 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx @@ -163,6 +163,18 @@ describe('V2PodsSidebar — direction C', () => { expect(within(dm).getByText('1d')).toBeInTheDocument(); }); + test('names the owner when another member also has a My Workspace pod', () => { + const own = pod('own', 'My Workspace', 'chat', [human('me')], { + createdBy: { _id: 'me', username: 'me' }, + }); + const shared = pod('shared', 'My Workspace', 'chat', [human('me'), human('alex'), human('third')], { + createdBy: { _id: 'alex', username: 'alex' }, + }); + renderSidebar([own, shared]); + expect(screen.getByRole('button', { name: /^My Workspace \(alex\)/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^My Workspace$/ })).toBeInTheDocument(); + }); + test('direct rows render the peer character for agents and Paper for unpicked people', () => { const agentDm = pod('agent-dm', 'Helper Agent', 'agent-dm', [ human('me'), { ...agent('builder-id'), username: 'Builder' }, diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index 818e11204c..4a96819788 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -25,6 +25,7 @@ import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; import { PERSONA_CARDS } from '../agents/personaCatalogData'; import { DEFAULT_INSTALL_SCOPES } from '../utils/agentInstallScopes'; +import { readPodVisits } from '../lib/podRecency'; const DEFAULT_AGENT_NAME = 'my-mcp-agent'; const DEFAULT_POD_TYPE = 'chat'; @@ -32,11 +33,6 @@ const DEFAULT_POD_TYPE = 'chat'; // 0.1.9 — a machine with an older global install must upgrade, not skip the // line because the binary already resolves. const CLI_INSTALL_COMMAND = 'npm i -g @commonlyai/cli@latest'; -// `--language` is a requiredOption of `agent init` (python is the only value), -// so the command exits 1 without it. This footnote is the first thing a -// stranger copies from the page the docs now send them to; it carried the -// flagless form until 2026-10-09 and one real user hit the exit (#2095). -const CLI_INIT_COMMAND = 'commonly agent init --language python --name --pod '; const MEMORY_FILE_NAME = 'MEMORY.md'; const AGENT_KIND = 'agent' as const; const HOSTED_STATUS_POLL_MS = 4000; @@ -354,14 +350,19 @@ const V2AgentBYO: React.FC = () => { // into (not in their list) must not survive as a phantom selection. const podKnown = podId && installablePods.some((p) => p._id === podId); if ((!podId || !podKnown) && installablePods.length > 0) { - // Default to the user's OWN pod (their private workspace), never the - // most-active pod. Fall back to the first non-public pod only if the - // user somehow owns none. + // A person coming from a team pod should install there, not in the + // private workspace created for them at signup. V2Layout records + // visits when a pod opens. Only consider pods still in this safe, + // installable list; a stale visit to a public pod cannot select it. + const visits = readPodVisits(); + const recent = installablePods + .filter((p) => visits[p._id] > 0) + .sort((a, b) => visits[b._id] - visits[a._id])[0]; const uid = currentUser?._id; const own = uid ? installablePods.find((p) => p.createdBy?._id && String(p.createdBy._id) === String(uid)) : undefined; - setPodId((own || installablePods[0])._id); + setPodId((recent || own || installablePods[0])._id); } } catch { // Defensive: keep the form usable; user will see the error on submit. @@ -988,7 +989,11 @@ const V2AgentBYO: React.FC = () => { > {pods.length === 0 && } {pods.map((p) => ( - + ))} @@ -1042,8 +1047,12 @@ const V2AgentBYO: React.FC = () => { )}

- {t('agentByo.footnote.preferCli')} {CLI_INSTALL_COMMAND}, {t('agentByo.footnote.then')}{' '} - {CLI_INIT_COMMAND}.{' '} + {t(listenerAvailable ? 'agentByo.footnote.cliRoute' + : listenAdapter === 'claude' ? 'agentByo.footnote.cliRouteClaudeUnavailable' + : 'agentByo.footnote.cliRouteCodexUnavailable', { + byo: t('agentByo.mode.byo'), + hosted: t('agentByo.mode.hosted'), + })}{' '} {t('agentByo.footnote.notSure')}{' '} {t('agentByo.footnote.mcpVsCli')} {' · '} diff --git a/frontend/src/v2/components/V2InviteRedeem.tsx b/frontend/src/v2/components/V2InviteRedeem.tsx index 9dc95c808a..369fdb2b69 100644 --- a/frontend/src/v2/components/V2InviteRedeem.tsx +++ b/frontend/src/v2/components/V2InviteRedeem.tsx @@ -23,6 +23,7 @@ interface InviteResolveResponse { token: string; pod: InvitePodInfo; alreadyMember?: boolean; + isOwner?: boolean; expiresAt?: string | null; } @@ -182,7 +183,12 @@ const V2InviteRedeem: React.FC = () => {

{podName}
{pod.description && ( -
{pod.description}
+
+ {/* Invite resolution sends the ownership fact, not the creator ID. + Keep the owner's original description while correcting it for guests. */} + {invite.isOwner === false && pod.name === 'My Workspace' && pod.description === 'Your private workspace' + ? t('inviteRedeem.sharedWorkspaceDescription') : pod.description} +
)}
{t('inviteRedeem.memberCount', { diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index 9e54f814ac..83d9b21d59 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -364,7 +364,11 @@ const V2PodsSidebar: React.FC = ({ ) : ( )} - {pod.name} + + {pod.name === 'My Workspace' && pod.createdBy?._id && String(pod.createdBy._id) !== String(currentUser?._id) + ? t('podNames.otherWorkspace', { name: pod.name, owner: pod.createdBy.username || t('podNames.shared') }) + : pod.name} + {count > 0 && (