From fe317a4aa7ba5fc37470f8cadc03abce20b49d48 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 04:00:37 -0700
Subject: [PATCH 1/8] feat: offer teammate invites at the first useful pod
moment
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
.../unit/routes/podInvites.funnel.test.js | 87 ++++++++++
backend/models/PodInvite.ts | 10 ++
backend/routes/admin/analytics.ts | 12 ++
backend/routes/podInvites.ts | 32 +++-
backend/services/inviteFunnelService.ts | 44 +++++
docs/development/invite-funnel.md | 37 +++++
.../preview/components-bring-teammate.html | 4 +
frontend/design-system/tokens.css | 1 +
frontend/src/i18n/locales/en.json | 19 +++
frontend/src/i18n/locales/zh-CN.json | 19 +++
.../src/v2/__tests__/V2BringTeammate.test.tsx | 104 ++++++++++++
.../src/v2/__tests__/V2ThreadStarter.test.tsx | 46 +++++-
.../v2/__tests__/teammateInviteMoment.test.ts | 32 ++++
.../v2/__tests__/v2-layout-invariants.test.ts | 12 ++
.../src/v2/components/V2BringTeammate.css | 26 +++
.../src/v2/components/V2BringTeammate.tsx | 153 ++++++++++++++++++
frontend/src/v2/components/V2Thread.tsx | 54 ++++---
.../src/v2/components/V2ThreadMessages.tsx | 8 +-
.../src/v2/components/V2ThreadStarter.tsx | 2 +
frontend/src/v2/utils/teammateInviteMoment.ts | 32 ++++
frontend/src/v2/v2.css | 1 +
21 files changed, 713 insertions(+), 22 deletions(-)
create mode 100644 backend/__tests__/unit/routes/podInvites.funnel.test.js
create mode 100644 backend/services/inviteFunnelService.ts
create mode 100644 docs/development/invite-funnel.md
create mode 100644 frontend/design-system/preview/components-bring-teammate.html
create mode 100644 frontend/src/v2/__tests__/V2BringTeammate.test.tsx
create mode 100644 frontend/src/v2/__tests__/teammateInviteMoment.test.ts
create mode 100644 frontend/src/v2/components/V2BringTeammate.css
create mode 100644 frontend/src/v2/components/V2BringTeammate.tsx
create mode 100644 frontend/src/v2/utils/teammateInviteMoment.ts
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..4f6a926932
--- /dev/null
+++ b/backend/__tests__/unit/routes/podInvites.funnel.test.js
@@ -0,0 +1,87 @@
+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);
+});
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..5ed1773428
--- /dev/null
+++ b/docs/development/invite-funnel.md
@@ -0,0 +1,37 @@
+# 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.
+
+Placement sources are `first-agent-post`, `scout-first-session`, and `solo-pod`;
+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.
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.
Copy invite link ×
+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.
Copied ×
+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.
Try again ×
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 4ab9bb0a73..77e6bb97dd 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -2563,5 +2563,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 51e6c8fe45..b45d89bf58 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2554,5 +2554,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..ade66bb848
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
@@ -0,0 +1,104 @@
+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.clearAllMocks();
+ 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');
+});
diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
index abe7129411..275a6b869d 100644
--- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
@@ -316,6 +316,8 @@ describe('V2Thread just-created Pod starter panel', () => {
expect(await screen.findByRole('button', { name: 'Run it here' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Bring your own' })).toBeInTheDocument();
expect(screen.queryByText('Bring in people or an agent, or start the conversation yourself.')).not.toBeInTheDocument();
+ expect(axios.post).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole('button', { name: 'Copy invite link' }));
await waitFor(() => {
expect(axios.post).toHaveBeenCalledWith(
'/api/pods/p1/invites',
@@ -326,7 +328,6 @@ describe('V2Thread just-created Pod starter panel', () => {
const inviteUrl = `${window.location.origin}/v2/invite/${token}`;
expect(await screen.findByLabelText('Invite link for this pod')).toHaveValue(inviteUrl);
- fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(inviteUrl));
fireEvent.click(screen.getByRole('button', { name: 'Run it here' }));
@@ -443,3 +444,46 @@ describe('V2Thread just-created Pod starter panel', () => {
expect(sessionStorage.getItem('v2.justCreated.p1')).toBeNull();
});
});
+
+describe('second-person prompt placements', () => {
+ beforeEach(() => {
+ jest.clearAllMocks(); sessionStorage.clear(); localStorage.clear();
+ axios.get.mockImplementation((url) => Promise.resolve({ data: /\/invites$/.test(url) ? [] : {} }));
+ });
+ const member = { _id: 'u1', username: 'solo-user', isBot: false };
+ const agent = { _id: 'bot', username: 'scout', isBot: true };
+ const messages = [
+ { id: '10', pod_id: 'p1', user_id: 'u1', content: 'Help with this launch', message_type: 'text', created_at: '2026-10-25T10:00:00Z', user: { username: 'solo-user', isBot: false } },
+ { id: '11', pod_id: 'p1', user_id: 'bot', content: 'Here is the first plan', message_type: 'text', created_at: '2026-10-25T10:01:00Z', user: { username: 'scout', isBot: true } },
+ ];
+ const withReply = (pod = { _id: 'p1', name: 'Launch', type: 'chat' }) => makeDetail({
+ pod, members: [member, agent], messages, hasMore: false,
+ agents: [{ agentName: 'scout', instanceId: 'default', status: 'active', displayName: 'Scout' }],
+ });
+ test('the empty state offers the card without minting or taking composer focus', async () => {
+ renderChat(makeDetail());
+ expect(await screen.findByRole('region', { name: 'Invite a teammate to My Workspace' })).toBeInTheDocument();
+ expect(axios.post).not.toHaveBeenCalled();
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+ test('the group reply offers one prompt and joining a second human removes it', async () => {
+ const detail = withReply();
+ const view = renderChat(detail);
+ expect(await screen.findByText(/They can work with Scout too/)).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: 'Copy invite link' })).toHaveLength(1);
+ view.rerender(
+
+ );
+ expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
+ });
+ test('Scout’s owned workspace discloses the conversation being shared', async () => {
+ const detail = withReply({ _id: 'p1', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1' } });
+ const username = 'scout-u0123456789';
+ detail.agents = [{ agentName: 'scout', instanceId: 'u0123456789', status: 'active', displayName: 'Scout' }];
+ detail.members = [member, { ...agent, username }];
+ detail.messages = [messages[0], { ...messages[1], user: { username, isBot: true } }];
+ renderChat(detail);
+ expect(await screen.findByText(/including this conversation with Scout/)).toBeInTheDocument();
+ expect(axios.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/v2/__tests__/teammateInviteMoment.test.ts b/frontend/src/v2/__tests__/teammateInviteMoment.test.ts
new file mode 100644
index 0000000000..fed2e01ba4
--- /dev/null
+++ b/frontend/src/v2/__tests__/teammateInviteMoment.test.ts
@@ -0,0 +1,32 @@
+import { isSoloHumanPod, teammateInviteMoment } from '../utils/teammateInviteMoment';
+import type { V2Message } from '../hooks/useV2PodDetail';
+
+const human = { _id: 'person', username: 'person', isBot: false };
+const agent = { _id: 'bot', username: 'scout-person', isBot: true };
+const msg = (id: string, user_id: string, username: string, isBot: boolean): V2Message => ({
+ id, pod_id: 'pod', user_id, content: 'A useful answer', message_type: 'text', created_at: '', user: { username, isBot },
+});
+const ask = msg('2', 'person', 'person', false);
+const answer = msg('3', 'bot', 'scout-person', true);
+const agentKeys = new Set(['scout-person']);
+
+test('only the sole human member of a group pod qualifies; unknown and DM members do not', () => {
+ expect(isSoloHumanPod({ _id: 'pod', name: 'Work' }, [human, agent], 'person')).toBe(true);
+ expect(isSoloHumanPod({ _id: 'pod', name: 'Work' }, [human, agent, { _id: 'other' }], 'person')).toBe(false);
+ expect(isSoloHumanPod({ _id: 'pod', name: 'Work' }, [human, agent], 'outsider')).toBe(false);
+ for (const type of ['agent-admin', 'agent-room', 'agent-dm']) {
+ expect(isSoloHumanPod({ _id: 'pod', name: 'Work', type }, [human, agent], 'person')).toBe(false);
+ }
+});
+test('waits for the person to ask, then chooses the first non-kernel agent reply', () => {
+ expect(teammateInviteMoment([answer], 'person', agentKeys, agentKeys, false)).toBeNull();
+ const welcome = { ...answer, id: '1' };
+ const system = { ...answer, id: 'kernel', author_kind: 'kernel' as const };
+ const failed = { ...answer, id: 'failed', metadata: { runFailed: true } };
+ expect(teammateInviteMoment([welcome, ask, system, failed, answer], 'person', agentKeys, agentKeys, false))
+ .toEqual({ messageId: '3', username: 'scout-person', source: 'first-agent-post' });
+});
+test('Scout in the owned workspace uses its separate disclosure and dismissal placement', () => {
+ expect(teammateInviteMoment([ask, answer], 'person', agentKeys, agentKeys, true)?.source).toBe('scout-first-session');
+ expect(teammateInviteMoment([ask, answer], 'person', agentKeys, new Set(), true)?.source).toBe('first-agent-post');
+});
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index 134fcf3ea2..c9d4be6d6a 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -4973,3 +4973,15 @@ test('TASK-435 runtime columns can shrink on phones without dropping the observe
expect(ruleBody(css, '.v2-runtime-controls__observed')).toContain('overflow-wrap: anywhere');
expect(css).toContain('content: attr(data-label)');
});
+
+test('teammate invite cards keep one-column phone controls at the 44px minimum', () => {
+ const css = read('../components/V2BringTeammate.css');
+ const phone = blockContaining(css, '@media (max-width: 760px)', '.v2-teammate');
+ expect(phone).toContain('grid-template-columns: minmax(0, 1fr)');
+ expect(phone).toContain('height: 44px');
+ expect(phone).toContain('flex-basis: 44px');
+ expect(css).toContain('width: calc(100% - 36px)');
+ expect(css).toContain('.v2-teammate.v2-teammate--message');
+ expect(css).toContain('.v2-teammate .v2-teammate__copy {');
+ expect(css).toContain('max-width: 100%');
+});
diff --git a/frontend/src/v2/components/V2BringTeammate.css b/frontend/src/v2/components/V2BringTeammate.css
new file mode 100644
index 0000000000..28fab4f737
--- /dev/null
+++ b/frontend/src/v2/components/V2BringTeammate.css
@@ -0,0 +1,26 @@
+.v2-teammate { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 16px; row-gap: 4px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-lg); padding: 12px 14px; background: var(--v2-surface); color: var(--v2-text-primary); text-align: left; min-width: 0; }
+.v2-teammate.v2-teammate--message { margin: 12px 0 12px 36px; width: calc(100% - 36px); max-width: 640px; box-sizing: border-box; }
+.v2-teammate--empty { width: 440px; max-width: 100%; margin: 16px auto 0; box-sizing: border-box; }
+.v2-teammate__kicker { font: 500 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); }
+.v2-teammate h3 { font: 600 13px/18px var(--v2-font); margin: 0; overflow-wrap: anywhere; }
+.v2-teammate__body { font: 400 12px/18px var(--v2-font); color: var(--v2-text-secondary); margin: 0; }
+.v2-teammate__acts { grid-column: 2; grid-row: 1 / 4; display: flex; align-items: center; gap: 8px; }
+.v2-teammate button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--v2-text-primary); border-radius: var(--v2-radius-sm); font: 600 13px/18px var(--v2-font); cursor: pointer; white-space: nowrap; }
+.v2-teammate .v2-teammate__copy { background: var(--v2-text-primary); color: var(--v2-surface); }
+.v2-teammate .v2-teammate__copy--quiet, .v2-teammate .v2-teammate__dismiss { background: var(--v2-surface); color: var(--v2-text-primary); border-color: var(--v2-border); }
+.v2-teammate .v2-teammate__copy:disabled { color: var(--v2-text-muted); cursor: wait; }
+.v2-teammate .v2-teammate__dismiss { width: 32px; padding: 0; flex: 0 0 32px; }
+.v2-teammate__link, .v2-teammate__error { grid-column: 1 / -1; min-width: 0; }
+.v2-teammate .v2-teammate__link input { box-sizing: border-box; width: 100%; height: 32px; padding: 0 10px; text-overflow: ellipsis; background: var(--v2-surface-tint); border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); font: 400 11px/16px var(--v2-font-mono); color: var(--v2-text-primary); }
+.v2-teammate__link p { font: 400 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); margin: 4px 0 0; }
+.v2-teammate .v2-teammate__manual { border-color: var(--v2-text-primary); }
+.v2-teammate__error { font: 400 11px/16px var(--v2-font-mono); color: var(--v2-danger-text); margin: 0; }
+.v2-teammate__live { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.v2-root .v2-teammate button:focus-visible, .v2-root .v2-teammate input:focus-visible { outline: 2px solid var(--v2-text-primary); outline-offset: 2px; }
+@media (max-width: 760px) {
+ .v2-teammate { grid-template-columns: minmax(0, 1fr); padding: 12px; }
+ .v2-teammate__acts { grid-column: 1; grid-row: auto; margin-top: 4px; }
+ .v2-teammate button, .v2-teammate .v2-teammate__link input { height: 44px; }
+ .v2-teammate__copy { flex: 1; min-width: 0; }
+ .v2-teammate .v2-teammate__dismiss { width: 44px; flex-basis: 44px; }
+}
diff --git a/frontend/src/v2/components/V2BringTeammate.tsx b/frontend/src/v2/components/V2BringTeammate.tsx
new file mode 100644
index 0000000000..5f787ac81c
--- /dev/null
+++ b/frontend/src/v2/components/V2BringTeammate.tsx
@@ -0,0 +1,153 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useV2Api } from '../hooks/useV2Api';
+import './V2BringTeammate.css';
+
+export type TeammateInviteSource = 'first-agent-post' | 'scout-first-session' | 'solo-pod';
+interface InviteLink {
+ token: string;
+ source?: string;
+ createdBy?: string | { _id?: string };
+ expiresAt?: string | null;
+ maxUses?: number | null;
+ uses?: number;
+}
+interface Props {
+ podId: string;
+ podName: string;
+ userId: string;
+ source: TeammateInviteSource;
+ agentName?: string;
+}
+
+/** State and pending requests belong to one viewer/pod/placement. */
+const BringTeammateContent: React.FC = ({ podId, podName, userId, source, agentName }) => {
+ const { t } = useTranslation();
+ const api = useV2Api();
+ const apiRef = useRef(api);
+ apiRef.current = api;
+ const storageKey = `v2.teammate.${podId}.${source}`;
+ const [dismissed, setDismissed] = useState(() => {
+ try { return localStorage.getItem(storageKey) === '1'; } catch { return false; }
+ });
+ const [url, setUrl] = useState('');
+ const [copyBusy, setCopyBusy] = useState(false);
+ const [state, setState] = useState<'idle' | 'creating' | 'copied' | 'manual' | 'error'>('idle');
+ const field = useRef(null);
+ const active = useRef(true);
+ const generation = useRef(0);
+ const busy = useRef(false);
+ const lookup = useRef | null>(null);
+ const copiedTimer = useRef>();
+
+ const readExisting = useCallback(async () => {
+ const rows = await apiRef.current.get(`/api/pods/${podId}/invites`);
+ const existing = rows.find((row) => {
+ const creator = typeof row.createdBy === 'string' ? row.createdBy : row.createdBy?._id;
+ return creator === userId && row.source === source && /^[a-f0-9]{32}$/.test(row.token)
+ && (!row.expiresAt || Date.parse(row.expiresAt) > Date.now())
+ && (row.maxUses == null || (row.uses || 0) < row.maxUses);
+ });
+ return existing ? `${window.location.origin}/v2/invite/${existing.token}` : '';
+ }, [podId, source, userId]);
+
+ useEffect(() => {
+ const epoch = ++generation.current;
+ active.current = !dismissed;
+ if (!dismissed) {
+ lookup.current = readExisting();
+ // Failed reads never authorize a blind mint; the next press retries GET.
+ void lookup.current.then((existing) => {
+ if (active.current && generation.current === epoch) setUrl(existing);
+ }).catch(() => { if (generation.current === epoch) lookup.current = null; });
+ }
+ return () => {
+ active.current = false;
+ clearTimeout(copiedTimer.current);
+ };
+ }, [dismissed, readExisting]);
+
+ useEffect(() => {
+ if (state === 'manual') { field.current?.focus(); field.current?.select(); }
+ }, [state]);
+
+ const copy = async () => {
+ if (busy.current) return;
+ busy.current = true;
+ setCopyBusy(true);
+ const epoch = generation.current;
+ const stillHere = () => active.current && generation.current === epoch;
+ setState(url ? 'idle' : 'creating');
+ clearTimeout(copiedTimer.current);
+ let destination = url;
+ try {
+ if (!destination) {
+ destination = await (lookup.current || readExisting());
+ if (!stillHere()) return;
+ if (!destination) {
+ const created = await apiRef.current.post<{ token: string }>(`/api/pods/${podId}/invites`, {
+ expiresInHours: 168, maxUses: null, source,
+ });
+ if (!/^[a-f0-9]{32}$/.test(created.token)) throw new Error('invalid invite token');
+ destination = `${window.location.origin}/v2/invite/${created.token}`;
+ }
+ }
+ if (!stillHere()) return;
+ setUrl(destination);
+ lookup.current = Promise.resolve(destination);
+ try {
+ await navigator.clipboard.writeText(destination);
+ if (!stillHere()) return;
+ setState('copied');
+ copiedTimer.current = setTimeout(() => setState('idle'), 2000);
+ } catch {
+ if (stillHere()) {
+ setState('manual');
+ field.current?.focus(); field.current?.select();
+ }
+ }
+ } catch {
+ // A lost POST response is recovered through GET on the next press.
+ lookup.current = null;
+ if (stillHere()) setState('error');
+ } finally {
+ busy.current = false;
+ if (stillHere()) setCopyBusy(false);
+ }
+ };
+
+ if (dismissed) return null;
+ const body = source === 'first-agent-post' ? 'agentPost' : source === 'scout-first-session' ? 'scout' : 'solo';
+ const label = state === 'creating' ? 'creating' : state === 'copied' ? 'copied'
+ : state === 'manual' ? 'copyRetry' : state === 'error' ? 'retry' : 'act';
+ return (
+
+ {t('teammate.kicker')}
+ {t('teammate.title', { podName })}
+ {t(`teammate.body.${body}`, { agentName })}
+
+
{ void copy(); }} disabled={copyBusy} aria-busy={copyBusy || undefined}>
+ {state === 'copied' && }
+ {t(`teammate.${label}`)}
+
+
{
+ setDismissed(true);
+ try { localStorage.setItem(storageKey, '1'); } catch { /* This mount still dismisses. */ }
+ }}>
+
+
+
+ {url &&
+
event.currentTarget.select()} />
+
{t(state === 'manual' ? 'teammate.clipboardFail' : 'teammate.meta')}
+
}
+ {state === 'error' && {t('teammate.error')}
}
+ {state === 'copied' ? t('teammate.live') : ''}
+
+ );
+};
+const V2BringTeammate: React.FC = (props) => (
+
+);
+export default V2BringTeammate;
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index 5bef1b12ff..ac8a98d416 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -12,6 +12,8 @@ import { type V2DecisionCardData, type V2DecisionRuling } from './V2DecisionCard
import V2ThreadMessages, { V2ThreadHistoryStatus } from './V2ThreadMessages';
import { landOnMessage } from './V2MessageRow';
import V2ThreadStarter from './V2ThreadStarter';
+import V2BringTeammate from './V2BringTeammate';
+import { isSoloHumanPod, teammateInviteMoment } from '../utils/teammateInviteMoment';
import {
HistorySearchState,
UseV2PodDetailResult,
@@ -45,6 +47,7 @@ const NO_AGENTS_HINT_KEY = 'v2.noAgentsHint';
const JUST_CREATED_POD_KEY = 'v2.justCreated';
const AGENT_INVITE_TAB: V2InviteTab = 'agent';
const PEOPLE_INVITE_TAB: V2InviteTab = 'people';
+const SOLO_INVITE_SOURCE = 'solo-pod';
// A sent direct message is durable in the room, but it is not a reply. Give
// the person a clear, bounded wait state instead of leaving the composer to
// imply that the agent is working. Two minutes is long enough for a normal
@@ -275,7 +278,6 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
const fileInputRef = useRef(null);
const composerInputRef = useRef(null);
const mentionDropdownRef = useRef(null);
- const starterInviteRequestedPodRef = useRef(null);
const starterInviteActivePodRef = useRef(null);
// Agent typing indicator state. Backend already emits agent_typing_start/
@@ -647,7 +649,6 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
useEffect(() => {
const podId = pod?._id || null;
starterInviteActivePodRef.current = podId;
- starterInviteRequestedPodRef.current = null;
setStarterInviteUrl('');
setStarterInviteError(null);
setStarterInviteLoading(false);
@@ -683,19 +684,20 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
}, [clearJustCreatedPod, justCreatedPodId, messages.length, pod?._id]);
const generateStarterInvite = useCallback(async (podId: string) => {
- starterInviteRequestedPodRef.current = podId;
setStarterInviteLoading(true);
setStarterInviteError(null);
setStarterInviteCopied(false);
try {
const data = await api.post<{ token?: string }>(`/api/pods/${podId}/invites`, {});
if (!data?.token) throw new Error('missing invite token');
- if (starterInviteActivePodRef.current !== podId) return;
- setStarterInviteUrl(`${window.location.origin}/v2/invite/${data.token}`);
+ if (starterInviteActivePodRef.current !== podId) return null;
+ const url = `${window.location.origin}/v2/invite/${data.token}`;
+ setStarterInviteUrl(url);
+ return url;
} catch {
- if (starterInviteActivePodRef.current !== podId) return;
- starterInviteRequestedPodRef.current = null;
+ if (starterInviteActivePodRef.current !== podId) return null;
setStarterInviteError(t('podChat.newPod.inviteError'));
+ return null;
} finally {
if (starterInviteActivePodRef.current === podId) setStarterInviteLoading(false);
}
@@ -787,21 +789,17 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
}
}, [api, currentUser?.username, detail, pod, starterHostedAvailable, starterHostedState, t]);
- useEffect(() => {
- if (!starterPanelVisible || !pod?._id || starterInviteUrl) return;
- if (starterInviteRequestedPodRef.current === pod._id) return;
- void generateStarterInvite(pod._id);
- }, [generateStarterInvite, pod?._id, starterInviteUrl, starterPanelVisible]);
-
const handleStarterInviteCopy = useCallback(async () => {
- if (!starterInviteUrl) return;
+ if (starterInviteLoading) return;
+ const url = starterInviteUrl || (pod?._id ? await generateStarterInvite(pod._id) : null);
+ if (!url) return;
try {
- await navigator.clipboard.writeText(starterInviteUrl);
+ await navigator.clipboard.writeText(url);
setStarterInviteCopied(true);
} catch {
// The read-only field remains selectable for manual copy.
}
- }, [starterInviteUrl]);
+ }, [generateStarterInvite, pod?._id, starterInviteLoading, starterInviteUrl]);
// Auto-scroll belongs to NEW messages only. Keyed on `messages.length` this
// also fired when a page of history was prepended, yanking the reader from
@@ -1443,6 +1441,20 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
// agent joins, without waiting for a second send to re-measure.
const noAgentsInPodLive = !(agents || []).some((agent) => agent.status === 'active');
+ const soloHuman = isSoloHumanPod(pod, members || [], currentUserId || '');
+ // This one condition isolates placement B if the private-workspace ruling
+ // changes. Its copy explicitly names the Scout conversation being shared.
+ const scoutWorkspace = viewerOwnsPod && pod.name === 'My Workspace';
+ const scoutKeys = new Set((agents || []).filter((agent) => agent.agentName === 'scout')
+ .map((agent) => buildAgentUsername(agent.agentName, agent.instanceId || 'default').toLowerCase()));
+ const teammateMoment = soloHuman && !hasMore && !loading && !firstRunVisible
+ ? teammateInviteMoment(messages, currentUserId || '', agentAuthorKeys, scoutKeys, scoutWorkspace) : null;
+ const teammateCard = teammateMoment ? : null;
+
// Bot-bot agent-dm — used to choose the "X and Y haven't talked yet" empty
// state and to phrase the read-only banner appropriately.
const botMembers = (members || []).filter((m) => m?.isBot);
@@ -1775,6 +1787,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
agentDisplayNames={agentDisplayNames}
agentTags={agentTags}
agentAuthorKeys={agentAuthorKeys}
+ afterMessage={teammateMoment ? { messageId: teammateMoment.messageId, content: teammateCard } : undefined}
onAuthorClick={onOpenMember ? handleAuthorClick : undefined}
onOpenFile={onOpenFile}
onReply={isReadOnly ? undefined : aimAtMessage}
@@ -1830,7 +1843,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
onRunHosted={() => { void startHostedInPod(); }}
onBringOwn={() => navigate(`/v2/agents/byo?pod=${encodeURIComponent(pod._id)}`)}
onCopyInvite={() => { void handleStarterInviteCopy(); }}
- onRetryInvite={() => { void generateStarterInvite(pod._id); }}
+ onRetryInvite={() => { void handleStarterInviteCopy(); }}
/>
) : undefined}
emptyState={!starterPanelVisible && !firstRunVisible && !loading && messages.length === 0 ? (
@@ -1861,8 +1874,11 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
{t('podChat.empty.agentDmText')}
>
) : (
- // Direction C: an empty pod is one mono line and a focused composer.
- {t('podChat.empty.noMessages')}
+ <>
+ {t('podChat.empty.noMessages')}
+ {soloHuman && }
+ >
)}
) : undefined}
diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx
index 8f849bca8c..33a2ed334f 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 = ({
)}
)}
+ {afterMessage?.messageId === String(message.id) && afterMessage.content}
{agentDeliveryHint?.messageId === message.id && (
= ({
)}
{renderedReplyEntries(shownReplies).map(({ sourceId, message: reply, ruling }, replyIndex, renderedReplies) => (
+
= ({
grouped={isGroupedWithPrevious(reply, renderedReplies[replyIndex - 1]?.message)}
insideThreadRoot={item.rootId}
/>
+ {afterMessage?.messageId === String(reply.id) && afterMessage.content}
+
))}
setOpen(item.rootId, false)}>
@@ -451,6 +456,7 @@ const V2ThreadMessages: React.FC = ({
)}
+ {collapsed && item.replies.some((reply) => String(reply.id) === afterMessage?.messageId) && afterMessage?.content}
);
})}
diff --git a/frontend/src/v2/components/V2ThreadStarter.tsx b/frontend/src/v2/components/V2ThreadStarter.tsx
index 87b7862d96..c736c4edc6 100644
--- a/frontend/src/v2/components/V2ThreadStarter.tsx
+++ b/frontend/src/v2/components/V2ThreadStarter.tsx
@@ -69,6 +69,8 @@ const V2ThreadStarter: React.FC = ({
{t('podChat.newPod.inviteTitle')}
{inviteLoading &&
{t('podChat.newPod.preparingInvite')}
}
+ {!inviteUrl && !inviteError &&
{t('teammate.act')} }
{inviteUrl && (
member.isBot !== true);
+ return humans.length === 1 && humans[0]._id === viewerId;
+}
+
+export function teammateInviteMoment(
+ messages: V2Message[], viewerId: string, agentKeys: Set
, scoutKeys: Set, workspace: boolean,
+): { messageId: string; username: string; source: TeammateInviteSource } | null {
+ let asked = false;
+ for (const message of messages) {
+ const authorId = message.user_id || (typeof message.userId === 'string' ? message.userId : message.userId?._id);
+ if (authorId === viewerId) { asked = true; continue; }
+ if (!asked || message.author_kind === 'kernel' || message.message_type === 'system' || !message.content.trim()) continue;
+ const author = message.user || (typeof message.userId === 'object' ? message.userId : undefined);
+ const username = author?.username || '';
+ const bot = typeof author?.isBot === 'boolean' ? author.isBot : agentKeys.has(username.toLowerCase());
+ const metadata = (message as V2Message & { metadata?: { runFailed?: boolean } }).metadata;
+ if (!bot || metadata?.runFailed) continue;
+ return {
+ messageId: String(message.id), username,
+ source: workspace && scoutKeys.has(username.toLowerCase()) ? 'scout-first-session' : 'first-agent-post',
+ };
+ }
+ return null;
+}
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 3a564c526d..8f37400235 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -90,6 +90,7 @@ body.modern-ui.v2-canvas {
--v2-info: #0891b2;
--v2-info-soft: #dff4f8;
--v2-danger: #ef4444;
+ --v2-danger-text: #b91c1c;
--v2-danger-soft: rgba(239, 68, 68, 0.10);
/* Agent role colors */
From 3b0f7e8b7c8ab07406ef241d31e8cff45762d47d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 04:06:51 -0700
Subject: [PATCH 2/8] fix: follow numeral and screen reader style conventions
for invites
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
frontend/src/i18n/locales/zh-CN.json | 2 +-
frontend/src/v2/components/V2BringTeammate.css | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index b45d89bf58..2f885ef354 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2568,7 +2568,7 @@
"copied": "已复制",
"copyRetry": "复制链接",
"retry": "重试",
- "meta": "7 天内有效 · 已登录的人都能用此链接加入",
+ "meta": "7天内有效 · 已登录的人都能用此链接加入",
"clipboardFail": "无法自动复制 · 链接已选中,请从这里复制",
"error": "无法生成链接 · 没有分享任何内容",
"live": "邀请链接已复制",
diff --git a/frontend/src/v2/components/V2BringTeammate.css b/frontend/src/v2/components/V2BringTeammate.css
index 28fab4f737..3bf0d42496 100644
--- a/frontend/src/v2/components/V2BringTeammate.css
+++ b/frontend/src/v2/components/V2BringTeammate.css
@@ -15,7 +15,7 @@
.v2-teammate__link p { font: 400 11px/16px var(--v2-font-mono); color: var(--v2-text-muted); margin: 4px 0 0; }
.v2-teammate .v2-teammate__manual { border-color: var(--v2-text-primary); }
.v2-teammate__error { font: 400 11px/16px var(--v2-font-mono); color: var(--v2-danger-text); margin: 0; }
-.v2-teammate__live { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+.v2-teammate__live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.v2-root .v2-teammate button:focus-visible, .v2-root .v2-teammate input:focus-visible { outline: 2px solid var(--v2-text-primary); outline-offset: 2px; }
@media (max-width: 760px) {
.v2-teammate { grid-template-columns: minmax(0, 1fr); padding: 12px; }
From c971c75efc53e24a3fcb9256ebda1ae6f5a63eaf Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 04:18:23 -0700
Subject: [PATCH 3/8] fix: preserve invite prompts in expanded threads and size
starter acts
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
.../unit/routes/podInvites.funnel.test.js | 19 ++++++++++++++++++
.../src/v2/__tests__/V2ThreadRestyle.test.tsx | 20 +++++++++++++++++++
.../v2/__tests__/v2-layout-invariants.test.ts | 8 ++++++++
.../src/v2/components/V2BringTeammate.css | 1 -
.../src/v2/components/V2ThreadMessages.tsx | 3 +++
frontend/src/v2/v2.css | 8 ++++++++
6 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/backend/__tests__/unit/routes/podInvites.funnel.test.js b/backend/__tests__/unit/routes/podInvites.funnel.test.js
index 4f6a926932..7495755d17 100644
--- a/backend/__tests__/unit/routes/podInvites.funnel.test.js
+++ b/backend/__tests__/unit/routes/podInvites.funnel.test.js
@@ -85,3 +85,22 @@ test('analytics excludes staff inviters and admits an explicit unobserved cohort
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/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
index 31694c083b..6e33f1b7be 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: Invite
} });
+ expect(screen.getAllByTestId('teammate-prompt')).toHaveLength(1);
+ expect(container.querySelector('.v2-thread-replies')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'Expand thread, 11 replies' }));
+ const prompt = screen.getByTestId('teammate-prompt');
+ expect(prompt.previousElementSibling).toHaveClass('v2-thread-replies__more');
+ expect(container.querySelector('#message-21')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '3 more replies' }));
+ expect(screen.getAllByTestId('teammate-prompt')).toHaveLength(1);
+ expect(screen.getByTestId('teammate-prompt').previousElementSibling).toHaveAttribute('id', 'message-21');
+ });
+
+ test('an expanded visible reply keeps its invite directly after that reply', () => {
+ renderThread({ afterMessage: { messageId: '31', content: Invite
} });
+ fireEvent.click(screen.getByRole('button', { name: 'Expand thread, 11 replies' }));
+ expect(screen.getAllByTestId('teammate-prompt')).toHaveLength(1);
+ expect(screen.getByTestId('teammate-prompt').previousElementSibling).toHaveAttribute('id', 'message-31');
+ });
+
test('expanded settled source replies render one ruled answer instead of a duplicate durable reply', () => {
const root = msg(20);
const source = msg(21, { thread_root_id: '20', content: 'Choose a path' });
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index c9d4be6d6a..008dd12016 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -4985,3 +4985,11 @@ test('teammate invite cards keep one-column phone controls at the 44px minimum',
expect(css).toContain('.v2-teammate .v2-teammate__copy {');
expect(css).toContain('max-width: 100%');
});
+
+test('standalone teammate invite button keeps desktop and phone touch targets', () => {
+ const css = read('../v2.css');
+ const standalone = ruleBody(css, '.v2-chat__new-pod-action--invite > .v2-chat__new-pod-copy');
+ expect(standalone).toContain('min-height: 32px');
+ expect(standalone).toContain('margin-top: auto');
+ expect(css).toMatch(/@media \(max-width: 760px\) \{\s*\.v2-chat__new-pod-action--invite > \.v2-chat__new-pod-copy \{ min-height: 44px;/);
+});
diff --git a/frontend/src/v2/components/V2BringTeammate.css b/frontend/src/v2/components/V2BringTeammate.css
index 3bf0d42496..5fa57cd540 100644
--- a/frontend/src/v2/components/V2BringTeammate.css
+++ b/frontend/src/v2/components/V2BringTeammate.css
@@ -16,7 +16,6 @@
.v2-teammate .v2-teammate__manual { border-color: var(--v2-text-primary); }
.v2-teammate__error { font: 400 11px/16px var(--v2-font-mono); color: var(--v2-danger-text); margin: 0; }
.v2-teammate__live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
-.v2-root .v2-teammate button:focus-visible, .v2-root .v2-teammate input:focus-visible { outline: 2px solid var(--v2-text-primary); outline-offset: 2px; }
@media (max-width: 760px) {
.v2-teammate { grid-template-columns: minmax(0, 1fr); padding: 12px; }
.v2-teammate__acts { grid-column: 1; grid-row: auto; margin-top: 4px; }
diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx
index 33a2ed334f..4fef09cfa2 100644
--- a/frontend/src/v2/components/V2ThreadMessages.tsx
+++ b/frontend/src/v2/components/V2ThreadMessages.tsx
@@ -385,6 +385,8 @@ const V2ThreadMessages: React.FC = ({
? item.replies
: item.replies.slice(item.replies.length - MAX_EXPANDED_REPLIES);
const hiddenReplies = item.replies.length - shownReplies.length;
+ const hiddenHasAfter = hiddenReplies > 0
+ && item.replies.slice(0, hiddenReplies).some((reply) => String(reply.id) === afterMessage?.messageId);
const following = state ? state.following : null;
const followLabel = following === true ? t('podChat.thread.following') : following === false ? t('podChat.thread.muted') : t('podChat.thread.follow');
return (
@@ -407,6 +409,7 @@ const V2ThreadMessages: React.FC = ({
{t('podChat.thread.moreReplies', { count: hiddenReplies })}
)}
+ {hiddenHasAfter && afterMessage?.content}
{renderedReplyEntries(shownReplies).map(({ sourceId, message: reply, ruling }, replyIndex, renderedReplies) => (
.v2-chat__new-pod-copy {
+ min-height: 32px;
+ margin-top: auto;
+}
+@media (max-width: 760px) {
+ .v2-chat__new-pod-action--invite > .v2-chat__new-pod-copy { min-height: 44px; }
+}
+
.v2-root button.v2-chat__new-pod-copy:hover {
background: var(--v2-surface-hover);
}
From 5d3c93bb2359bed903f42f9b3a06639aea37764d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 04:41:25 -0700
Subject: [PATCH 4/8] fix: retain invite requests when thread reply slots move
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
docs/development/invite-funnel.md | 6 ++++
.../src/v2/__tests__/V2ThreadRestyle.test.tsx | 2 ++
.../src/v2/__tests__/V2ThreadStarter.test.tsx | 27 ++++++++++++++++
.../src/v2/components/V2BringTeammate.tsx | 32 ++++++++++++-------
frontend/src/v2/components/V2Thread.tsx | 16 ++++++----
.../src/v2/components/V2ThreadMessages.tsx | 2 +-
6 files changed, 65 insertions(+), 20 deletions(-)
diff --git a/docs/development/invite-funnel.md b/docs/development/invite-funnel.md
index 5ed1773428..cffc2a16cb 100644
--- a/docs/development/invite-funnel.md
+++ b/docs/development/invite-funnel.md
@@ -35,3 +35,9 @@ shared classification policy and never returns invite tokens or person IDs.
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/src/v2/__tests__/V2ThreadRestyle.test.tsx b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
index 6e33f1b7be..b9779f4a02 100644
--- a/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx
@@ -230,11 +230,13 @@ describe('direction C threading restyle (PR 2b)', () => {
{ kind: 'card', rootId: '20', replyCount: replies.length, participants: [], lastActivityAt: null, replies },
],
settledDecisionByMessageId: new Map([['21', { value: 'Ship it', by: 'Sam', messageId: '22' }]]),
+ afterMessage: { messageId: '21', content: Invite
},
});
fireEvent.click(screen.getByRole('button', { name: 'Expand thread, 2 replies' }));
const block = container.querySelector('.v2-thread-block--open');
expect(block.querySelectorAll('.v2-thread-replies .v2-msg')).toHaveLength(1);
+ expect(screen.getAllByTestId('settled-invite')).toHaveLength(1);
expect(block.querySelector('[data-testid="decision-ruling-row"]')).toHaveTextContent('Ship it');
expect(block.querySelectorAll('#message-22')).toHaveLength(1);
Element.prototype.scrollIntoView = jest.fn();
diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
index 275a6b869d..9dfa36378e 100644
--- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
@@ -448,6 +448,8 @@ describe('V2Thread just-created Pod starter panel', () => {
describe('second-person prompt placements', () => {
beforeEach(() => {
jest.clearAllMocks(); sessionStorage.clear(); localStorage.clear();
+ axios.post.mockReset();
+ navigator.clipboard.writeText.mockReset().mockResolvedValue(undefined);
axios.get.mockImplementation((url) => Promise.resolve({ data: /\/invites$/.test(url) ? [] : {} }));
});
const member = { _id: 'u1', username: 'solo-user', isBot: false };
@@ -486,4 +488,29 @@ describe('second-person prompt placements', () => {
expect(await screen.findByText(/including this conversation with Scout/)).toBeInTheDocument();
expect(axios.post).not.toHaveBeenCalled();
});
+ test('a pending invite press completes once across expanding and collapsing its thread', async () => {
+ let finish;
+ axios.post.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ const detail = withReply();
+ detail.messages = [messages[0], { ...messages[1], thread_root_id: '10' }];
+ renderChat(detail);
+ fireEvent.click(await screen.findByRole('button', { name: 'Copy invite link' }));
+ await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1));
+ fireEvent.click(screen.getByRole('button', { name: 'Expand thread, 1 reply' }));
+ expect(screen.getByRole('button', { name: 'Creating link…' })).toBeDisabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse' }));
+ await act(async () => { finish({ data: { token: 'a'.repeat(32) } }); });
+ expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument();
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${window.location.origin}/v2/invite/${'a'.repeat(32)}`);
+ expect(axios.post).toHaveBeenCalledTimes(1);
+ });
+
+ test('dismissing the state-owned invite leaves the transcript and composer mounted', async () => {
+ renderChat(withReply());
+ fireEvent.click(await screen.findByRole('button', { name: 'Dismiss' }));
+ expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
+ expect(screen.getAllByText('Rendered message')).toHaveLength(2);
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
});
diff --git a/frontend/src/v2/components/V2BringTeammate.tsx b/frontend/src/v2/components/V2BringTeammate.tsx
index 5f787ac81c..07b344cf73 100644
--- a/frontend/src/v2/components/V2BringTeammate.tsx
+++ b/frontend/src/v2/components/V2BringTeammate.tsx
@@ -18,10 +18,13 @@ interface Props {
userId: string;
source: TeammateInviteSource;
agentName?: string;
+ enabled?: boolean;
+ /** Keep request state above transcript slots that move during thread toggles. */
+ children?: (card: React.ReactNode) => React.ReactNode;
}
/** State and pending requests belong to one viewer/pod/placement. */
-const BringTeammateContent: React.FC = ({ podId, podName, userId, source, agentName }) => {
+const BringTeammateContent: React.FC = ({ podId, podName, userId, source, agentName, enabled = true, children }) => {
const { t } = useTranslation();
const api = useV2Api();
const apiRef = useRef(api);
@@ -52,21 +55,26 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source,
}, [podId, source, userId]);
useEffect(() => {
- const epoch = ++generation.current;
+ generation.current += 1;
active.current = !dismissed;
- if (!dismissed) {
- lookup.current = readExisting();
- // Failed reads never authorize a blind mint; the next press retries GET.
- void lookup.current.then((existing) => {
- if (active.current && generation.current === epoch) setUrl(existing);
- }).catch(() => { if (generation.current === epoch) lookup.current = null; });
- }
+ lookup.current = null;
return () => {
active.current = false;
clearTimeout(copiedTimer.current);
};
}, [dismissed, readExisting]);
+ useEffect(() => {
+ if (!enabled || dismissed || lookup.current) return;
+ const epoch = generation.current;
+ lookup.current = readExisting();
+ // Visibility changes do not cancel a press while its thread slot moves.
+ // Scope changes and explicit dismissal still invalidate the request.
+ void lookup.current.then((existing) => {
+ if (active.current && generation.current === epoch) setUrl(existing);
+ }).catch(() => { if (generation.current === epoch) lookup.current = null; });
+ }, [enabled, dismissed, readExisting]);
+
useEffect(() => {
if (state === 'manual') { field.current?.focus(); field.current?.select(); }
}, [state]);
@@ -116,11 +124,10 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source,
}
};
- if (dismissed) return null;
const body = source === 'first-agent-post' ? 'agentPost' : source === 'scout-first-session' ? 'scout' : 'solo';
const label = state === 'creating' ? 'creating' : state === 'copied' ? 'copied'
: state === 'manual' ? 'copyRetry' : state === 'error' ? 'retry' : 'act';
- return (
+ const card = enabled && !dismissed ? (
{t('teammate.kicker')}
{t('teammate.title', { podName })}
@@ -145,7 +152,8 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source,
{state === 'error' && {t('teammate.error')}
}
{state === 'copied' ? t('teammate.live') : ''}
- );
+ ) : null;
+ return children ? <>{children(card)}> : card;
};
const V2BringTeammate: React.FC = (props) => (
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index ac8a98d416..812728258b 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -1449,11 +1449,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
.map((agent) => buildAgentUsername(agent.agentName, agent.instanceId || 'default').toLowerCase()));
const teammateMoment = soloHuman && !hasMore && !loading && !firstRunVisible
? teammateInviteMoment(messages, currentUserId || '', agentAuthorKeys, scoutKeys, scoutWorkspace) : null;
- const teammateCard = teammateMoment ? : null;
+
+ const teammateSource = teammateMoment?.source || (scoutWorkspace ? 'scout-first-session' : 'first-agent-post');
// Bot-bot agent-dm — used to choose the "X and Y haven't talked yet" empty
// state and to phrase the read-only banner appropriately.
@@ -1776,7 +1773,11 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
{firstTaskError && {firstTaskError}
}
-
+ {(teammateCard) => = ({ 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 4fef09cfa2..31e8d54f8e 100644
--- a/frontend/src/v2/components/V2ThreadMessages.tsx
+++ b/frontend/src/v2/components/V2ThreadMessages.tsx
@@ -429,7 +429,7 @@ const V2ThreadMessages: React.FC = ({
grouped={isGroupedWithPrevious(reply, renderedReplies[replyIndex - 1]?.message)}
insideThreadRoot={item.rootId}
/>
- {afterMessage?.messageId === String(reply.id) && afterMessage.content}
+ {afterMessage?.messageId === sourceId && afterMessage.content}
))}
From 6648c786c2bce573fae563e9e97f998726c9c05b Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 04:50:57 -0700
Subject: [PATCH 5/8] Require loaded history and share invite retry reads
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
.../src/v2/__tests__/V2BringTeammate.test.tsx | 20 ++++++++++++++++++-
.../src/v2/__tests__/V2ThreadStarter.test.tsx | 7 +++++++
.../useV2PodDetailPodChange.test.tsx | 14 +++++++++++++
.../src/v2/components/V2BringTeammate.tsx | 12 ++++++-----
frontend/src/v2/components/V2Thread.tsx | 2 +-
frontend/src/v2/hooks/useV2PodDetail.ts | 7 +++++++
6 files changed, 55 insertions(+), 7 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
index ade66bb848..8a188ae5a3 100644
--- a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
+++ b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx
@@ -13,7 +13,7 @@ const props = { podId: 'pod', podName: 'Work', userId: 'person', source: 'solo-p
const press = () => fireEvent.click(screen.getByRole('button', { name: 'teammate.act' }));
beforeEach(() => {
localStorage.clear();
- jest.clearAllMocks();
+ jest.resetAllMocks();
mockGet.mockResolvedValue([]);
mockPost.mockResolvedValue({ token });
mockWrite.mockResolvedValue(undefined);
@@ -102,3 +102,21 @@ test('changing pods cannot carry a prior invite or late clipboard result into th
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__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
index 9dfa36378e..b98ac350e4 100644
--- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
@@ -67,6 +67,7 @@ const makeDetail = (overrides = {}) => ({
agents: [],
sendMessage: jest.fn(),
loading: false,
+ initialMessagesLoaded: true,
error: null,
refresh: jest.fn(),
...overrides,
@@ -109,6 +110,12 @@ describe('V2Thread teaching empty states', () => {
expect(document.querySelector('.v2-thread__empty-line')).toHaveTextContent('no messages yet');
});
+ test('a failed first history read never shows a solo-pod invite', () => {
+ renderChat(makeDetail({ initialLoadComplete: true, initialMessagesLoaded: false, error: 'Messages unavailable' }));
+ expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
+ expect(document.querySelector('.v2-teammate')).toBeNull();
+ });
+
test('compact header shows singular agents and names both counts for assistive tech', () => {
const detail = makeDetail({
agents: [{ agentName: 'openclaw', instanceId: 'aria', displayName: 'Aria' }],
diff --git a/frontend/src/v2/__tests__/useV2PodDetailPodChange.test.tsx b/frontend/src/v2/__tests__/useV2PodDetailPodChange.test.tsx
index f0809ea56f..9a55cb7c53 100644
--- a/frontend/src/v2/__tests__/useV2PodDetailPodChange.test.tsx
+++ b/frontend/src/v2/__tests__/useV2PodDetailPodChange.test.tsx
@@ -22,6 +22,19 @@ const deferred = () => {
describe('useV2PodDetail room changes', () => {
beforeEach(() => jest.clearAllMocks());
+ it.each([200, 404, 500])('only a successful first messages request marks history loaded (HTTP %s)', async (status) => {
+ mockApi.get.mockImplementation((url) => {
+ if (url.startsWith('/api/messages/')) return status === 200
+ ? Promise.resolve([]) : Promise.reject({ response: { status }, message: 'unavailable' });
+ if (url.includes('/agents')) return Promise.resolve({ agents: [] });
+ return Promise.resolve({ _id: 'room', name: 'room', members: [] });
+ });
+ const { result } = renderHook(() => useV2PodDetail('room'));
+ await waitFor(() => expect(result.current.initialLoadComplete).toBe(true));
+ expect(result.current.initialMessagesLoaded).toBe(status === 200);
+ expect(result.current.messages).toEqual([]);
+ });
+
it('clears immediately and rejects a late response from the previous room', async () => {
const oldMessages = deferred();
const newMessages = deferred();
@@ -39,6 +52,7 @@ describe('useV2PodDetail room changes', () => {
act(() => rerender({ podId: 'new-room' }));
expect(result.current.messages).toEqual([]);
+ expect(result.current.initialMessagesLoaded).toBe(false);
await act(async () => {
oldMessages.resolve([{
diff --git a/frontend/src/v2/components/V2BringTeammate.tsx b/frontend/src/v2/components/V2BringTeammate.tsx
index 07b344cf73..762c984a03 100644
--- a/frontend/src/v2/components/V2BringTeammate.tsx
+++ b/frontend/src/v2/components/V2BringTeammate.tsx
@@ -67,12 +67,13 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source,
useEffect(() => {
if (!enabled || dismissed || lookup.current) return;
const epoch = generation.current;
- lookup.current = readExisting();
+ const request = readExisting();
+ lookup.current = request;
// Visibility changes do not cancel a press while its thread slot moves.
// Scope changes and explicit dismissal still invalidate the request.
- void lookup.current.then((existing) => {
- if (active.current && generation.current === epoch) setUrl(existing);
- }).catch(() => { if (generation.current === epoch) lookup.current = null; });
+ void request.then((existing) => {
+ if (active.current && generation.current === epoch && lookup.current === request) setUrl(existing);
+ }).catch(() => { if (generation.current === epoch && lookup.current === request) lookup.current = null; });
}, [enabled, dismissed, readExisting]);
useEffect(() => {
@@ -90,7 +91,8 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source,
let destination = url;
try {
if (!destination) {
- destination = await (lookup.current || readExisting());
+ if (!lookup.current) lookup.current = readExisting();
+ destination = await lookup.current;
if (!stillHere()) return;
if (!destination) {
const created = await apiRef.current.post<{ token: string }>(`/api/pods/${podId}/invites`, {
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index 812728258b..140275440f 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -1877,7 +1877,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
) : (
<>
{t('podChat.empty.noMessages')}
- {soloHuman && }
>
)}
diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts
index 3342066812..8c9fcf5dfe 100644
--- a/frontend/src/v2/hooks/useV2PodDetail.ts
+++ b/frontend/src/v2/hooks/useV2PodDetail.ts
@@ -182,6 +182,8 @@ export interface UseV2PodDetailResult {
loading: boolean;
/** True after the current pod's initial pod/message read has settled. */
initialLoadComplete?: boolean;
+ /** The current pod has returned a successful messages page, including an empty page. */
+ initialMessagesLoaded?: boolean;
error: string | null;
/** Send failures belong beside the composer; load failures use `error`. */
sendError: string | null;
@@ -297,6 +299,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
// state distinct from a settled empty page so landing cannot declare a
// target absent before the initial messages request has even started.
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
+ const [initialMessagesLoaded, setInitialMessagesLoaded] = useState(false);
const [error, setError] = useState(null);
const [sendError, setSendError] = useState(null);
const [hasMore, setHasMore] = useState(false);
@@ -339,6 +342,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
setError(null);
setSendError(null);
setInitialLoadComplete(false);
+ setInitialMessagesLoaded(false);
setHasMore(false);
setLoadingOlder(false);
updateHistorySearch({
@@ -364,6 +368,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
const list = Array.isArray(data) ? data : [];
if (activePodIdRef.current !== id) return;
const normalized = chronologicalMessages(list.map(normalizeMessage));
+ setInitialMessagesLoaded(true);
if (reconnect) {
// A socket event (or a previously loaded history page) can arrive
// while the catch-up request is in flight. Keep those rows and dedupe.
@@ -591,6 +596,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
loadingOlderRef.current = false;
setLoadingOlder(false);
setInitialLoadComplete(false);
+ setInitialMessagesLoaded(false);
if (!podId) {
setPod(null);
setMessages([]);
@@ -798,6 +804,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
agents,
loading,
initialLoadComplete,
+ initialMessagesLoaded,
error,
sendError,
hasMore,
From 43e88eb934e07d8046557cf18b513bd29e2de774 Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 05:03:43 -0700
Subject: [PATCH 6/8] Keep first reply invites off until initial history
succeeds
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
.../src/v2/__tests__/V2ThreadStarter.test.tsx | 9 ++++++
.../useV2PodDetailReconnect.test.tsx | 29 +++++++++++++++++++
frontend/src/v2/components/V2Thread.tsx | 2 +-
frontend/src/v2/hooks/useV2PodDetail.ts | 5 ++--
4 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
index b98ac350e4..3977cfbbfb 100644
--- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
@@ -485,6 +485,15 @@ describe('second-person prompt placements', () => {
);
expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
});
+ test('socket messages after a failed initial history read do not qualify as the first reply', () => {
+ const failed = makeDetail({ initialLoadComplete: true, initialMessagesLoaded: false, error: 'History unavailable', hasMore: false });
+ const view = renderChat(failed);
+ const liveDetail = { ...withReply(), initialMessagesLoaded: false, error: 'History unavailable' };
+ view.rerender( );
+ expect(screen.getAllByText('Rendered message')).toHaveLength(2);
+ expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
+ });
+
test('Scout’s owned workspace discloses the conversation being shared', async () => {
const detail = withReply({ _id: 'p1', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1' } });
const username = 'scout-u0123456789';
diff --git a/frontend/src/v2/__tests__/useV2PodDetailReconnect.test.tsx b/frontend/src/v2/__tests__/useV2PodDetailReconnect.test.tsx
index 8e2f343394..c0a4b714c8 100644
--- a/frontend/src/v2/__tests__/useV2PodDetailReconnect.test.tsx
+++ b/frontend/src/v2/__tests__/useV2PodDetailReconnect.test.tsx
@@ -82,6 +82,7 @@ describe('useV2PodDetail reconnect', () => {
'before-outage', 'missed-reply', 'socket-and-server',
]);
expect(new Set(result.current.messages.map((item) => item.id)).size).toBe(3);
+ expect(result.current.initialMessagesLoaded).toBe(true);
});
it('keeps a socket message the catch-up response does not contain', async () => {
@@ -119,6 +120,34 @@ describe('useV2PodDetail reconnect', () => {
]);
});
+ it('keeps first-reply eligibility off after a failed initial read and a full reconnect page', async () => {
+ let reads = 0;
+ mockApi.get.mockImplementation((url) => {
+ if (url === '/api/messages/pod-1?limit=50') {
+ reads += 1;
+ return reads === 1 ? Promise.reject(new Error('history unavailable'))
+ : Promise.resolve(Array.from({ length: 50 }, (_, index) => message(`history-${index}`, index)));
+ }
+ if (url.includes('/agents')) return Promise.resolve({ agents: [] });
+ return Promise.resolve({ _id: 'pod-1', name: 'Pod', members: [] });
+ });
+ const { result, rerender } = renderHook(() => useV2PodDetail('pod-1'));
+ await waitFor(() => expect(result.current.initialLoadComplete).toBe(true));
+ expect(result.current.initialMessagesLoaded).toBe(false);
+ const receive = mockSocket.on.mock.calls.filter(([event]) => event === 'newMessage').at(-1)[1];
+ act(() => {
+ receive({ ...message('viewer-live', 51), user_id: 'human-1', user: { username: 'human', isBot: false } });
+ receive({ ...message('bot-live', 52), user: { username: 'scout', isBot: true } });
+ });
+ expect(result.current.messages).toHaveLength(2);
+ act(() => { mockConnected = false; rerender(); });
+ act(() => { mockConnected = true; rerender(); });
+ await waitFor(() => expect(result.current.messages).toHaveLength(52));
+ expect(result.current.initialMessagesLoaded).toBe(false);
+ expect(result.current.messages.map((row) => row.id)).toContain('viewer-live');
+ expect(result.current.messages.map((row) => row.id)).toContain('bot-live');
+ });
+
it('does not fetch twice when the socket connects for the first time', async () => {
mockConnected = false;
const { result, rerender } = renderHook(() => useV2PodDetail('pod-1'));
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index 140275440f..449ae9b021 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -1447,7 +1447,7 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
const scoutWorkspace = viewerOwnsPod && pod.name === 'My Workspace';
const scoutKeys = new Set((agents || []).filter((agent) => agent.agentName === 'scout')
.map((agent) => buildAgentUsername(agent.agentName, agent.instanceId || 'default').toLowerCase()));
- const teammateMoment = soloHuman && !hasMore && !loading && !firstRunVisible
+ const teammateMoment = soloHuman && detail.initialMessagesLoaded === true && !hasMore && !loading && !firstRunVisible
? teammateInviteMoment(messages, currentUserId || '', agentAuthorKeys, scoutKeys, scoutWorkspace) : null;
const teammateSource = teammateMoment?.source || (scoutWorkspace ? 'scout-first-session' : 'first-agent-post');
diff --git a/frontend/src/v2/hooks/useV2PodDetail.ts b/frontend/src/v2/hooks/useV2PodDetail.ts
index 8c9fcf5dfe..b9238bf0ad 100644
--- a/frontend/src/v2/hooks/useV2PodDetail.ts
+++ b/frontend/src/v2/hooks/useV2PodDetail.ts
@@ -182,7 +182,7 @@ export interface UseV2PodDetailResult {
loading: boolean;
/** True after the current pod's initial pod/message read has settled. */
initialLoadComplete?: boolean;
- /** The current pod has returned a successful messages page, including an empty page. */
+ /** The current pod has returned a successful initial messages page, including an empty page. */
initialMessagesLoaded?: boolean;
error: string | null;
/** Send failures belong beside the composer; load failures use `error`. */
@@ -368,7 +368,8 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => {
const list = Array.isArray(data) ? data : [];
if (activePodIdRef.current !== id) return;
const normalized = chronologicalMessages(list.map(normalizeMessage));
- setInitialMessagesLoaded(true);
+ // A catch-up page cannot establish whether earlier history exists.
+ if (!reconnect) setInitialMessagesLoaded(true);
if (reconnect) {
// A socket event (or a previously loaded history page) can arrive
// while the catch-up request is in flight. Keep those rows and dedupe.
From 2412d5c9425ab97cf4e3597a37b1b8155c7f905c Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 06:39:09 -0700
Subject: [PATCH 7/8] Drop the private Scout first reply invite placement
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
docs/development/invite-funnel.md | 7 ++++++-
frontend/src/v2/__tests__/V2ThreadStarter.test.tsx | 6 ++++--
frontend/src/v2/components/V2Thread.tsx | 7 ++++---
3 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/docs/development/invite-funnel.md b/docs/development/invite-funnel.md
index cffc2a16cb..711cbe1908 100644
--- a/docs/development/invite-funnel.md
+++ b/docs/development/invite-funnel.md
@@ -12,8 +12,13 @@ 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`;
-legacy/menu writes default to `pod-menu`. Source is client-provided attribution,
+`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
diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
index 3977cfbbfb..44f84613fd 100644
--- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
+++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
@@ -494,14 +494,16 @@ describe('second-person prompt placements', () => {
expect(screen.queryByRole('button', { name: 'Copy invite link' })).not.toBeInTheDocument();
});
- test('Scout’s owned workspace discloses the conversation being shared', async () => {
+ test('Lily’s Drop B ruling suppresses the automatic card in Scout’s owned workspace', async () => {
const detail = withReply({ _id: 'p1', name: 'My Workspace', type: 'chat', createdBy: { _id: 'u1' } });
const username = 'scout-u0123456789';
detail.agents = [{ agentName: 'scout', instanceId: 'u0123456789', status: 'active', displayName: 'Scout' }];
detail.members = [member, { ...agent, username }];
detail.messages = [messages[0], { ...messages[1], user: { username, isBot: true } }];
renderChat(detail);
- expect(await screen.findByText(/including this conversation with Scout/)).toBeInTheDocument();
+ await act(async () => { await Promise.resolve(); });
+ expect(document.querySelector('.v2-teammate')).toBeNull();
+ expect(axios.get.mock.calls.filter(([url]) => url.endsWith('/invites'))).toHaveLength(0);
expect(axios.post).not.toHaveBeenCalled();
});
test('a pending invite press completes once across expanding and collapsing its thread', async () => {
diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx
index 449ae9b021..23412685f8 100644
--- a/frontend/src/v2/components/V2Thread.tsx
+++ b/frontend/src/v2/components/V2Thread.tsx
@@ -1442,12 +1442,13 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in
const noAgentsInPodLive = !(agents || []).some((agent) => agent.status === 'active');
const soloHuman = isSoloHumanPod(pod, members || [], currentUserId || '');
- // This one condition isolates placement B if the private-workspace ruling
- // changes. Its copy explicitly names the Scout conversation being shared.
+ // Do not offer the automatic first-reply invite in the private Scout workspace
+ // (the placement-B privacy ruling). Group replies and empty pods qualify separately.
const scoutWorkspace = viewerOwnsPod && pod.name === 'My Workspace';
const scoutKeys = new Set((agents || []).filter((agent) => agent.agentName === 'scout')
.map((agent) => buildAgentUsername(agent.agentName, agent.instanceId || 'default').toLowerCase()));
- const teammateMoment = soloHuman && detail.initialMessagesLoaded === true && !hasMore && !loading && !firstRunVisible
+ const teammateMoment = soloHuman && !scoutWorkspace && detail.initialMessagesLoaded === true
+ && !hasMore && !loading && !firstRunVisible
? teammateInviteMoment(messages, currentUserId || '', agentAuthorKeys, scoutKeys, scoutWorkspace) : null;
const teammateSource = teammateMoment?.source || (scoutWorkspace ? 'scout-first-session' : 'first-agent-post');
From ca1b7a453974a9e2a46670c255645cbfda3c480c Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sun, 25 Oct 2026 08:38:13 -0700
Subject: [PATCH 8/8] copy: adjudicate Chinese teammate invite wording
Co-authored-by: lark
Agent-Model: codex/gpt-6.1-sol/high
---
frontend/src/i18n/locales/zh-CN.json | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index 2f885ef354..7b67baf1a7 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -2559,18 +2559,18 @@
"kicker": "这里只有你",
"title": "邀请队友加入 {{podName}}",
"body": {
- "agentPost": "他们也可以和 {{agentName}} 一起工作,并能看到这个 Pod 里的全部内容,包括之前的消息。",
+ "agentPost": "队友也能和 {{agentName}} 一起工作,还能看到这个 Pod 里的全部内容,包括之前的消息。",
"scout": "Scout 可以把这些工作分给你们两人。他们能看到这个 Pod,包括你和 Scout 的这段对话。",
- "solo": "他们可以在这里发消息,并和这个 Pod 里的智能体一起工作。这里发过的内容他们都能看到。"
+ "solo": "队友可以在这里发消息,和这个 Pod 里的智能体一起工作,也能看到这里的全部内容,包括之前的消息。"
},
"act": "复制邀请链接",
"creating": "正在生成链接…",
"copied": "已复制",
"copyRetry": "复制链接",
"retry": "重试",
- "meta": "7天内有效 · 已登录的人都能用此链接加入",
- "clipboardFail": "无法自动复制 · 链接已选中,请从这里复制",
- "error": "无法生成链接 · 没有分享任何内容",
+ "meta": "7天内有效 · 任何人登录后都能凭此链接加入",
+ "clipboardFail": "自动复制失败 · 链接已选中,请手动复制",
+ "error": "链接生成失败 · 未分享任何内容",
"live": "邀请链接已复制",
"dismiss": "关闭"
}