diff --git a/backend/__tests__/unit/routes/podInvites.funnel.test.js b/backend/__tests__/unit/routes/podInvites.funnel.test.js
new file mode 100644
index 0000000000..7495755d17
--- /dev/null
+++ b/backend/__tests__/unit/routes/podInvites.funnel.test.js
@@ -0,0 +1,106 @@
+const express = require('express');
+const request = require('supertest');
+const { setupMongoDb, closeMongoDb } = require('../../utils/testUtils');
+jest.mock('../../../middleware/auth', () => (req, res, next) => {
+ if (!req.get('x-user')) return res.sendStatus(401);
+ req.userId = req.get('x-user');
+ req.user = { id: req.userId, role: req.get('x-role') || 'user' };
+ return next();
+});
+jest.mock('../../../middleware/adminAuth', () => (req, res, next) => (
+ req.user.role === 'admin' ? next() : res.sendStatus(403)
+));
+jest.mock('../../../services/agentIdentityService', () => ({ DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']) }));
+jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({ assertInstalledAgentMayJoinPod: jest.fn() }));
+const User = require('../../../models/User');
+const Pod = require('../../../models/Pod');
+const { PodInvite } = require('../../../models/PodInvite');
+const inviteRoutes = require('../../../routes/podInvites');
+const analyticsRoutes = require('../../../routes/admin/analytics');
+let app;
+let alice;
+let bob;
+let staff;
+let pod;
+beforeAll(async () => {
+ await setupMongoDb();
+ app = express(); app.use(express.json());
+ app.use('/api', inviteRoutes);
+ app.use('/api/admin/analytics', analyticsRoutes);
+});
+afterAll(closeMongoDb);
+beforeEach(async () => {
+ await Promise.all([User.deleteMany({}), Pod.deleteMany({}), PodInvite.deleteMany({})]);
+ alice = await User.create({ username: 'alice-cohort', email: 'alice@outside-company.org', password: 'test-password-123',
+ onboarding: { podOpenedAt: new Date('2026-10-20T12:00:00Z') } });
+ bob = await User.create({ username: 'bob-cohort', email: 'bob@outside-company.org', password: 'test-password-123',
+ onboarding: { podOpenedAt: new Date('2026-10-24T12:00:00Z') } });
+ staff = await User.create({ username: 'staff-cohort', email: 'staff@outside-company.org', role: 'admin', password: 'test-password-123' });
+ pod = await Pod.create({ name: 'Invite test', type: 'team', members: [alice._id], createdBy: alice._id });
+});
+const mint = (body = {}, user = alice) => request(app).post(`/api/pods/${pod.id}/invites`).set('x-user', user.id).send(body);
+const redeem = (token, user) => request(app).post(`/api/invites/${token}/redeem`).set('x-user', user.id).send({});
+const stats = () => request(app).get('/api/admin/analytics/invites?days=30').set('x-user', staff.id).set('x-role', 'admin');
+
+test('mint binds the cohort to the authenticated inviter and normalizes the UI source', async () => {
+ const response = await mint({ source: 'first-agent-post', createdBy: bob.id, inviterFirstSessionAt: '1900-01-01' });
+ expect(response.status).toBe(201);
+ const invite = await PodInvite.findOne({ token: response.body.token }).lean();
+ expect(String(invite.createdBy)).toBe(alice.id);
+ expect(invite.source).toBe('first-agent-post');
+ const links = await request(app).get(`/api/pods/${pod.id}/invites`).set('x-user', alice.id).expect(200);
+ expect(links.body).toEqual(expect.arrayContaining([expect.objectContaining({
+ token: response.body.token, source: 'first-agent-post', createdBy: expect.objectContaining({ _id: alice.id }),
+ })]));
+ expect(invite.inviterFirstSessionAt.toISOString()).toBe('2026-10-20T12:00:00.000Z');
+ const unknown = await mint({ source: 'not-a-placement' });
+ expect((await PodInvite.findOne({ token: unknown.body.token })).source).toBe('pod-menu');
+ expect((await mint({}, bob)).status).toBe(403);
+});
+test('one outside teammate converts once; self uses, repeats and staff do not inflate it', async () => {
+ const response = await mint({ source: 'scout-first-session' });
+ const token = response.body.token;
+ await redeem(token, alice).expect(200);
+ await redeem(token, staff).expect(200);
+ expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toBeUndefined();
+ await redeem(token, bob).expect(200);
+ const first = (await PodInvite.findOne({ token })).firstTeammateJoinedAt;
+ expect(first).toBeInstanceOf(Date);
+ await redeem(token, bob).expect(200);
+ expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toEqual(first);
+ const report = await stats().expect(200);
+ expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 1, inviters: 1, convertedInviters: 1 }]);
+ expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 1 });
+ expect(JSON.stringify(report.body)).not.toContain(token);
+ expect(JSON.stringify(report.body)).not.toContain(alice.id);
+});
+test('analytics excludes staff inviters and admits an explicit unobserved cohort', async () => {
+ await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: staff._id } });
+ await mint({}, staff).expect(201);
+ await User.updateOne({ _id: alice._id }, { $unset: { 'onboarding.podOpenedAt': '' } });
+ await mint({ source: 'solo-pod' }).expect(201);
+ const report = await stats().expect(200);
+ expect(report.body.cohorts).toEqual([{ firstSessionDay: null, created: 1, used: 0, inviters: 1, convertedInviters: 0 }]);
+ expect(report.body.sources['pod-menu']).toBeUndefined();
+ await request(app).get('/api/admin/analytics/invites').set('x-user', alice.id).expect(403);
+ await request(app).get('/api/admin/analytics/invites').expect(401);
+});
+
+test('bot redemption does not count as a new outside teammate', async () => {
+ const bot = await User.create({ username: 'bot-cohort', email: 'bot@outside-company.org', password: 'test-password-123', isBot: true });
+ const response = await mint({ source: 'scout-first-session' });
+ await redeem(response.body.token, bot).expect(200);
+ expect((await PodInvite.findOne({ token: response.body.token })).firstTeammateJoinedAt).toBeUndefined();
+ const report = await stats().expect(200);
+ expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 0 });
+});
+test('invites outside the creation window contribute to neither cohorts nor sources', async () => {
+ await PodInvite.create({ token: 'f'.repeat(32), podId: pod.id, createdBy: alice.id,
+ source: 'pod-menu', createdAt: new Date(Date.now() - 31 * 86400000),
+ inviterFirstSessionAt: new Date('2026-09-01T12:00:00Z'), firstTeammateJoinedAt: new Date(),
+ });
+ await mint({ source: 'solo-pod' }).expect(201);
+ const report = await stats().expect(200);
+ expect(report.body.sources).toEqual({ 'solo-pod': { created: 1, used: 0 } });
+ expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 0, inviters: 1, convertedInviters: 0 }]);
+});
diff --git a/backend/models/PodInvite.ts b/backend/models/PodInvite.ts
index 42c678d818..da93950e3e 100644
--- a/backend/models/PodInvite.ts
+++ b/backend/models/PodInvite.ts
@@ -1,5 +1,7 @@
import mongoose, { Schema, Document, Types, Model } from 'mongoose';
+export const INVITE_SOURCES = ['pod-menu', 'first-agent-post', 'scout-first-session', 'solo-pod'] as const;
+
export interface IPodInvite extends Document {
token: string;
podId: Types.ObjectId;
@@ -9,6 +11,11 @@ export interface IPodInvite extends Document {
useCount: number;
revokedAt?: Date | null;
lastUsedAt?: Date | null;
+ source?: typeof INVITE_SOURCES[number];
+ /** Snapshot of the inviter's first observed workspace session, never client-set. */
+ inviterFirstSessionAt?: Date;
+ /** First distinct outside human joining; repeated uses and bots never set it. */
+ firstTeammateJoinedAt?: Date;
createdAt: Date;
updatedAt: Date;
isUsable(): boolean;
@@ -24,6 +31,9 @@ const PodInviteSchema = new Schema(
useCount: { type: Number, default: 0 },
revokedAt: { type: Date, default: null },
lastUsedAt: { type: Date, default: null },
+ source: { type: String, enum: INVITE_SOURCES },
+ inviterFirstSessionAt: { type: Date },
+ firstTeammateJoinedAt: { type: Date },
},
{ timestamps: true },
);
diff --git a/backend/routes/admin/analytics.ts b/backend/routes/admin/analytics.ts
index 5c305f46ee..93b209c4e9 100644
--- a/backend/routes/admin/analytics.ts
+++ b/backend/routes/admin/analytics.ts
@@ -55,6 +55,18 @@ const pgUserIdsWithMessages = async (userIds: string[]): Promise> =>
return new Set(result.rows.map((r: { user_id: string }) => String(r.user_id)));
};
+// Invite creation -> first distinct outside teammate, by inviter session cohort.
+router.get('/invites', adminReadLimiter, auth, adminAuth, async (req: any, res: any) => {
+ try {
+ const days = Math.max(1, Math.min(90, parseInt(req.query.days, 10) || 30));
+ const { readInviteFunnel } = require('../../services/inviteFunnelService');
+ return res.json(await readInviteFunnel(days));
+ } catch (error) {
+ console.error('[invite-funnel] read failed:', (error as Error).message);
+ return res.status(503).json({ error: 'invite_funnel_unavailable' });
+ }
+});
+
// GET /api/admin/analytics/funnel?days=30
// Daily signup cohorts with: signups, attached ≥1 agent, sent ≥1 message,
// returned D1/D7 (proxy), plus a totals row with rates.
diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts
index b87f15557d..38802649fc 100644
--- a/backend/routes/podInvites.ts
+++ b/backend/routes/podInvites.ts
@@ -14,7 +14,8 @@ import { assertInstalledAgentMayJoinPod } from '../services/outsiderGrantAgentJo
const router = express.Router();
const auth = require('../middleware/auth');
const Pod = require('../models/Pod');
-const { PodInvite } = require('../models/PodInvite');
+const { PodInvite, INVITE_SOURCES } = require('../models/PodInvite');
+const { HUMAN_FILTER, internalAccountExclusion } = require('../services/userClassification');
const getUserId = (req: any) => req.userId || req.user?.id || req.user?._id;
@@ -65,6 +66,16 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any,
return res.status(403).json({ msg: 'Only pod members can create invites' });
}
const { expiresInHours, maxUses } = req.body || {};
+ // Attribution is optional telemetry. Old clients still mint the same link.
+ // Source is a closed UI label; the cohort is always read from User.
+ const source = INVITE_SOURCES.find((candidate: string) => candidate === req.body?.source) || 'pod-menu';
+ let inviterFirstSessionAt: Date | undefined;
+ try {
+ const inviter = await User.findById(userId).select('onboarding.podOpenedAt').lean();
+ inviterFirstSessionAt = inviter?.onboarding?.podOpenedAt || undefined;
+ } catch (error) {
+ console.warn('[invite-funnel] cohort read failed:', (error as Error).message);
+ }
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = expiresInHours && Number(expiresInHours) > 0
? new Date(Date.now() + Number(expiresInHours) * 3600 * 1000)
@@ -73,6 +84,8 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any,
token,
podId: pod._id,
createdBy: userId,
+ source,
+ ...(inviterFirstSessionAt ? { inviterFirstSessionAt } : {}),
expiresAt,
maxUses: maxUses && Number(maxUses) > 0 ? Number(maxUses) : null,
});
@@ -117,6 +130,7 @@ router.get('/pods/:podId/invites', inviteReadRateLimit, auth, async (req: any, r
expiresAt: invite.expiresAt,
maxUses: invite.maxUses,
uses: Number(invite.useCount) || 0,
+ source: invite.source || null,
})));
} catch (err: any) {
console.error('List invites failed:', err.message);
@@ -268,6 +282,22 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an
pod.members.push(userId);
pod.updatedAt = new Date();
await pod.save();
+ // A token use by its author, an existing member, a bot or an internal
+ // account is not the outside team's second-person conversion.
+ if (String(invite.createdBy) !== String(userId)) {
+ try {
+ const outsidePerson = await User.exists({
+ _id: userId, ...HUMAN_FILTER, ...internalAccountExclusion(),
+ });
+ if (outsidePerson) await PodInvite.updateOne({
+ _id: invite._id, firstTeammateJoinedAt: { $exists: false },
+ }, { $set: { firstTeammateJoinedAt: new Date() } });
+ } catch (error) {
+ // Membership has already been committed. Telemetry cannot undo a
+ // successful join or turn it into a misleading client error.
+ console.warn('[invite-funnel] conversion write failed:', (error as Error).message);
+ }
+ }
}
invite.useCount += 1;
invite.lastUsedAt = new Date();
diff --git a/backend/services/inviteFunnelService.ts b/backend/services/inviteFunnelService.ts
new file mode 100644
index 0000000000..94d9397db6
--- /dev/null
+++ b/backend/services/inviteFunnelService.ts
@@ -0,0 +1,44 @@
+import User from '../models/User';
+import { PodInvite, INVITE_SOURCES } from '../models/PodInvite';
+import { HUMAN_FILTER, internalAccountExclusion } from './userClassification';
+
+/** Aggregate counts only: token material and people never leave this service. */
+export async function readInviteFunnel(days: number) {
+ const since = new Date(Date.now() - days * 86400000);
+ const invites = await PodInvite.find({ createdAt: { $gte: since } })
+ .select('createdBy source inviterFirstSessionAt firstTeammateJoinedAt').lean();
+ const people = await User.find({
+ _id: { $in: [...new Set(invites.map((invite) => String(invite.createdBy)))] },
+ ...HUMAN_FILTER, ...internalAccountExclusion(),
+ }).select('_id').lean();
+ const outside = new Set(people.map((person) => String(person._id)));
+ const cohorts = new Map; convertedInviters: Set }>();
+ const sources: Record = {};
+ for (const invite of invites) {
+ const inviterId = String(invite.createdBy);
+ if (!outside.has(inviterId)) continue;
+ const day = invite.inviterFirstSessionAt?.toISOString().slice(0, 10) || null;
+ const key = day || 'unobserved';
+ const cohort = cohorts.get(key) || { firstSessionDay: day, created: 0, used: 0,
+ inviters: new Set(), convertedInviters: new Set() };
+ cohort.created += 1;
+ cohort.inviters.add(inviterId);
+ if (invite.firstTeammateJoinedAt) { cohort.used += 1; cohort.convertedInviters.add(inviterId); }
+ cohorts.set(key, cohort);
+ const source = INVITE_SOURCES.find((candidate) => candidate === invite.source) || 'unattributed';
+ sources[source] ||= { created: 0, used: 0 };
+ sources[source].created += 1;
+ if (invite.firstTeammateJoinedAt) sources[source].used += 1;
+ }
+ return {
+ days, createdSince: since.toISOString(), cohortBasis: 'inviter-first-observed-pod-open',
+ // Legacy useCount counts even an existing member's repeated redemption.
+ // The new marker measures new outside-human joins, forward from deploy.
+ usageMeasuredFromDeploy: true,
+ cohorts: [...cohorts.values()].sort((a, b) => (a.firstSessionDay || '').localeCompare(b.firstSessionDay || ''))
+ .map(({ inviters, convertedInviters, ...cohort }) => ({ ...cohort,
+ inviters: inviters.size, convertedInviters: convertedInviters.size })),
+ sources,
+ };
+}
diff --git a/docs/development/invite-funnel.md b/docs/development/invite-funnel.md
new file mode 100644
index 0000000000..711cbe1908
--- /dev/null
+++ b/docs/development/invite-funnel.md
@@ -0,0 +1,48 @@
+# The second-person invite moment
+
+`V2BringTeammate` uses the existing pod invite model and member-authorized API.
+It appears for the sole human in a group pod, after that person's first agent
+reply, in the owned Scout workspace, or in the ordinary empty state. An
+incomplete history cannot establish the first reply. Direct-message pod types
+are excluded. Membership changes remove the prompt when a second human joins.
+
+An impression performs only GET. A press reuses this user's usable link for the
+same placement, or creates one with a seven-day expiry. Clipboard refusal keeps
+and selects that link; retry never creates another token. Returning links are
+shown without claiming the new browser has copied them. Dismissal is local to
+the pod and placement. The just-created-pod starter also creates only on press.
+
+The automatic first-reply card is suppressed in the viewer-owned My Workspace
+to avoid prompting them to share a private Scout conversation. The group-pod
+reply and one-person empty-state placements remain available.
+
+Placement sources are `first-agent-post`, `scout-first-session`, and `solo-pod`;
+`scout-first-session` is retained for historical attribution. Legacy/menu writes
+default to `pod-menu`. Source is client-provided attribution,
+not an authorization claim. Inviter identity and cohort time are server-bound.
+
+## Measurement
+
+Admin-only `GET /api/admin/analytics/invites?days=30` returns aggregate counts,
+with a one-to-90-day window. It excludes internal and bot inviters using the
+shared classification policy and never returns invite tokens or person IDs.
+
+- `created`: successful invite rows, counted by creation time and source.
+- `used`: the first **new outside human** joining with that invite. Self uses,
+ already-member repeats, staff and bots do not convert a team. The existing
+ raw `useCount` still counts redemptions and is unchanged.
+- `inviters` / `convertedInviters`: distinct outside inviters in each cohort.
+- Cohort: UTC day of the inviter's immutable `onboarding.podOpenedAt`, copied at
+ mint. This is the first observed opening of a pod they created, not a claim
+ about an unobserved authentication session. Missing legacy marks are an
+ explicit `null` cohort.
+
+Usage measurement starts at deployment: legacy redemption counters are not
+backfilled as teammate joins. Telemetry failures do not undo a successful mint
+or membership join. The endpoint reports the forward-only limitation explicitly.
+
+The inline card's request state is owned above `V2ThreadMessages`; its view can
+move between collapsed, visible and hidden-prefix reply slots without cancelling
+a pending press. The owner renders no wrapper DOM, and remains inert until the
+first-reply moment qualifies. Pod, viewer and placement still key its lifetime.
+Settled reply views match the original source message ID, not the ruling's ID.
diff --git a/frontend/design-system/preview/components-bring-teammate.html b/frontend/design-system/preview/components-bring-teammate.html
new file mode 100644
index 0000000000..590fcbcb51
--- /dev/null
+++ b/frontend/design-system/preview/components-bring-teammate.html
@@ -0,0 +1,4 @@
+Bring a teammate · Signal
Bring a teammate
One card in three placements. Tokens and exact copy follow TASK-515. The interactive proof uses the production component; this page is the static component specimen.
+
only you here
Invite a teammate to Launch planning
They can work with Scout too. They'll see everything in this pod, including earlier messages.
+
only you here
Invite a teammate to My Workspace
Scout can split this work between you. They'll see this pod, including this conversation with Scout.
works for 7 days · anyone signed in with this link can join
+
only you here
Invite a teammate to Launch planning
They can post here and work with the agents in this pod. They'll see everything posted here.
couldn't create a link · nothing was shared
diff --git a/frontend/design-system/tokens.css b/frontend/design-system/tokens.css
index 25a39e3a02..1d546e62d6 100644
--- a/frontend/design-system/tokens.css
+++ b/frontend/design-system/tokens.css
@@ -53,6 +53,7 @@
--c-info: #0891b2; /* cyan — distinct from accent blue */
--c-info-soft: #dff4f8;
--c-danger: #ef4444;
+ --c-danger-text: #b91c1c;
--c-danger-soft: rgba(239, 68, 68, 0.10);
/* ---------- Agent role accents (avatar tints, role chips) ---------- */
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index a67c519851..aa1d279865 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2621,5 +2621,24 @@
"hours": "{{n}}h",
"days": "{{n}}d"
}
+ },
+ "teammate": {
+ "kicker": "only you here",
+ "title": "Invite a teammate to {{podName}}",
+ "body": {
+ "agentPost": "They can work with {{agentName}} too. They'll see everything in this pod, including earlier messages.",
+ "scout": "Scout can split this work between you. They'll see this pod, including this conversation with Scout.",
+ "solo": "They can post here and work with the agents in this pod. They'll see everything posted here."
+ },
+ "act": "Copy invite link",
+ "creating": "Creating link…",
+ "copied": "Copied",
+ "copyRetry": "Copy link",
+ "retry": "Try again",
+ "meta": "works for 7 days · anyone signed in with this link can join",
+ "clipboardFail": "couldn't copy automatically · the link is selected, copy it from here",
+ "error": "couldn't create a link · nothing was shared",
+ "live": "Invite link copied",
+ "dismiss": "Dismiss"
}
}
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index b90d94af5e..1395badf2a 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2612,5 +2612,24 @@
"hours": "{{n}}小时",
"days": "{{n}}天"
}
+ },
+ "teammate": {
+ "kicker": "这里只有你",
+ "title": "邀请队友加入 {{podName}}",
+ "body": {
+ "agentPost": "队友也能和 {{agentName}} 一起工作,还能看到这个 Pod 里的全部内容,包括之前的消息。",
+ "scout": "Scout 可以把这些工作分给你们两人。他们能看到这个 Pod,包括你和 Scout 的这段对话。",
+ "solo": "队友可以在这里发消息,和这个 Pod 里的智能体一起工作,也能看到这里的全部内容,包括之前的消息。"
+ },
+ "act": "复制邀请链接",
+ "creating": "正在生成链接…",
+ "copied": "已复制",
+ "copyRetry": "复制链接",
+ "retry": "重试",
+ "meta": "7天内有效 · 任何人登录后都能凭此链接加入",
+ "clipboardFail": "自动复制失败 · 链接已选中,请手动复制",
+ "error": "链接生成失败 · 未分享任何内容",
+ "live": "邀请链接已复制",
+ "dismiss": "关闭"
}
}
diff --git a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
new file mode 100644
index 0000000000..8a188ae5a3
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
@@ -0,0 +1,122 @@
+import React from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import V2BringTeammate from '../components/V2BringTeammate';
+
+const mockGet = jest.fn();
+const mockPost = jest.fn();
+const mockWrite = jest.fn();
+const mockApi = { get: mockGet, post: mockPost };
+jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi }));
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+const token = 'a'.repeat(32);
+const props = { podId: 'pod', podName: 'Work', userId: 'person', source: 'solo-pod' as const };
+const press = () => fireEvent.click(screen.getByRole('button', { name: 'teammate.act' }));
+beforeEach(() => {
+ localStorage.clear();
+ jest.resetAllMocks();
+ mockGet.mockResolvedValue([]);
+ mockPost.mockResolvedValue({ token });
+ mockWrite.mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: mockWrite } });
+});
+test('render only reads; a press creates one seven-day link and copies it', async () => {
+ render();
+ await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
+ expect(mockPost).not.toHaveBeenCalled();
+ const button = screen.getByRole('button', { name: 'teammate.act' });
+ fireEvent.click(button); fireEvent.click(button);
+ await screen.findByText('teammate.live');
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ expect(mockPost).toHaveBeenCalledWith('/api/pods/pod/invites', { expiresInHours: 168, maxUses: null, source: 'solo-pod' });
+ expect(mockWrite).toHaveBeenCalledWith(`${window.location.origin}/v2/invite/${token}`);
+});
+test('returning reuses only this user’s usable link from this placement without claiming it was copied', async () => {
+ mockGet.mockResolvedValue([
+ { token: 'b'.repeat(32), source: 'solo-pod', createdBy: { _id: 'other' } },
+ { token: 'c'.repeat(32), source: 'solo-pod', createdBy: 'person', maxUses: 0, uses: 0 },
+ { token: 'd'.repeat(32), source: 'solo-pod', createdBy: 'person', expiresAt: '2020-01-01T00:00:00Z' },
+ { token, source: 'solo-pod', createdBy: { _id: 'person' }, expiresAt: new Date(Date.now() + 60000).toISOString() },
+ ]);
+ render();
+ await screen.findByDisplayValue(`${window.location.origin}/v2/invite/${token}`);
+ expect(mockWrite).not.toHaveBeenCalled();
+ expect(screen.queryByText('teammate.copied')).not.toBeInTheDocument();
+ press();
+ await screen.findByText('teammate.live');
+ expect(mockPost).not.toHaveBeenCalled();
+});
+test('clipboard refusal selects the retained link, and retry never mints again', async () => {
+ mockWrite.mockRejectedValue(new Error('clipboard denied'));
+ render();
+ press();
+ const field = await screen.findByRole('textbox');
+ await waitFor(() => expect(field).toHaveFocus());
+ expect((field as HTMLInputElement).selectionEnd).toBe((field as HTMLInputElement).value.length);
+ mockWrite.mockResolvedValue(undefined);
+ fireEvent.click(screen.getByRole('button', { name: 'teammate.copyRetry' }));
+ await screen.findByText('teammate.live');
+ expect(mockPost).toHaveBeenCalledTimes(1);
+});
+test('failed POST is explicit; retry recovers an accepted link through GET', async () => {
+ mockPost.mockRejectedValue(new Error('lost response'));
+ render();
+ press();
+ await screen.findByRole('alert');
+ mockGet.mockResolvedValue([{ token, source: 'solo-pod', createdBy: 'person' }]);
+ fireEvent.click(screen.getByRole('button', { name: 'teammate.retry' }));
+ await screen.findByText('teammate.live');
+ expect(mockPost).toHaveBeenCalledTimes(1);
+});
+test('a failed existing-link read does not permit blind minting', async () => {
+ mockGet.mockRejectedValue(new Error('offline'));
+ render();
+ press();
+ await screen.findByRole('alert');
+ expect(mockPost).not.toHaveBeenCalled();
+});
+test('dismissal is scoped to pod and placement and cancels a pending pre-mint lookup', async () => {
+ let resolve!: (rows: unknown[]) => void;
+ mockGet.mockReturnValue(new Promise((done) => { resolve = done; }));
+ const { unmount } = render();
+ press();
+ fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' }));
+ await act(async () => { resolve([]); });
+ expect(mockPost).not.toHaveBeenCalled();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ unmount();
+ render();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+});
+
+
+test('changing pods cannot carry a prior invite or late clipboard result into the next pod', async () => {
+ let finish!: () => void;
+ mockWrite.mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
+ const view = render();
+ press();
+ await waitFor(() => expect(mockWrite).toHaveBeenCalledTimes(1));
+ view.rerender();
+ await act(async () => { finish(); });
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ expect(screen.queryByText('teammate.live')).not.toBeInTheDocument();
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ expect(mockGet).toHaveBeenCalledWith('/api/pods/different/invites');
+});
+
+test('a retry shares its read across visibility changes and retains the new link', async () => {
+ let finish!: (rows: unknown[]) => void;
+ mockGet.mockRejectedValueOnce(new Error('offline'))
+ .mockReturnValueOnce(new Promise((resolve) => { finish = resolve; }));
+ const view = render();
+ await act(async () => { await Promise.resolve(); });
+ press();
+ await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
+ view.rerender();
+ view.rerender();
+ await act(async () => { finish([]); });
+ await screen.findByText('teammate.live');
+ expect(mockGet).toHaveBeenCalledTimes(2);
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ expect(mockWrite).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole('textbox')).toHaveValue(`${window.location.origin}/v2/invite/${token}`);
+});
diff --git a/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
index 31694c083b..b9779f4a02 100644
--- a/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
@@ -198,6 +198,26 @@ describe('direction C threading restyle (PR 2b)', () => {
expect(container.querySelector('.v2-thread-block--open')).toBeNull();
});
+ test('the first-reply invite survives collapse and the hidden expanded prefix exactly once', () => {
+ const { container } = renderThread({ afterMessage: { messageId: '21', content:
>
) : (
- // Direction C: an empty pod is one mono line and a focused composer.
- {t('podChat.empty.noMessages')}
+ <>
+ {t('podChat.empty.noMessages')}
+ {soloHuman && detail.initialMessagesLoaded && }
+ >
)}
) : undefined}
@@ -1862,7 +1880,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
onAddAgent={onOpenInvite ? () => onOpenInvite(AGENT_INVITE_TAB) : undefined}
messagesContainerRef={messagesContainerRef}
messagesEndRef={messagesEndRef}
- />
+ />}
+
{ void retryHistorySearch(); }}
diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx
index 8f849bca8c..31e8d54f8e 100644
--- a/frontend/src/v2/components/V2ThreadMessages.tsx
+++ b/frontend/src/v2/components/V2ThreadMessages.tsx
@@ -59,6 +59,7 @@ interface V2ThreadMessagesProps {
error: string | null;
starterPanel?: React.ReactNode;
emptyState?: React.ReactNode;
+ afterMessage?: { messageId: string; content: React.ReactNode };
agentDeliveryHint?: { messageId: string; mentionHandle: string } | null;
// A pod with no installed agent answers nothing by construction, so there is
// no handle to suggest — a different row, in the same slot.
@@ -150,6 +151,7 @@ const V2ThreadMessages: React.FC = ({
error,
starterPanel,
emptyState,
+ afterMessage,
agentDeliveryHint,
noAgentsHint,
onAddAgent,
@@ -331,6 +333,7 @@ const V2ThreadMessages: React.FC = ({
)}