diff --git a/backend/__tests__/unit/routes/podInvites.funnel.test.js b/backend/__tests__/unit/routes/podInvites.funnel.test.js new file mode 100644 index 0000000000..7495755d17 --- /dev/null +++ b/backend/__tests__/unit/routes/podInvites.funnel.test.js @@ -0,0 +1,106 @@ +const express = require('express'); +const request = require('supertest'); +const { setupMongoDb, closeMongoDb } = require('../../utils/testUtils'); +jest.mock('../../../middleware/auth', () => (req, res, next) => { + if (!req.get('x-user')) return res.sendStatus(401); + req.userId = req.get('x-user'); + req.user = { id: req.userId, role: req.get('x-role') || 'user' }; + return next(); +}); +jest.mock('../../../middleware/adminAuth', () => (req, res, next) => ( + req.user.role === 'admin' ? next() : res.sendStatus(403) +)); +jest.mock('../../../services/agentIdentityService', () => ({ DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']) })); +jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({ assertInstalledAgentMayJoinPod: jest.fn() })); +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const { PodInvite } = require('../../../models/PodInvite'); +const inviteRoutes = require('../../../routes/podInvites'); +const analyticsRoutes = require('../../../routes/admin/analytics'); +let app; +let alice; +let bob; +let staff; +let pod; +beforeAll(async () => { + await setupMongoDb(); + app = express(); app.use(express.json()); + app.use('/api', inviteRoutes); + app.use('/api/admin/analytics', analyticsRoutes); +}); +afterAll(closeMongoDb); +beforeEach(async () => { + await Promise.all([User.deleteMany({}), Pod.deleteMany({}), PodInvite.deleteMany({})]); + alice = await User.create({ username: 'alice-cohort', email: 'alice@outside-company.org', password: 'test-password-123', + onboarding: { podOpenedAt: new Date('2026-10-20T12:00:00Z') } }); + bob = await User.create({ username: 'bob-cohort', email: 'bob@outside-company.org', password: 'test-password-123', + onboarding: { podOpenedAt: new Date('2026-10-24T12:00:00Z') } }); + staff = await User.create({ username: 'staff-cohort', email: 'staff@outside-company.org', role: 'admin', password: 'test-password-123' }); + pod = await Pod.create({ name: 'Invite test', type: 'team', members: [alice._id], createdBy: alice._id }); +}); +const mint = (body = {}, user = alice) => request(app).post(`/api/pods/${pod.id}/invites`).set('x-user', user.id).send(body); +const redeem = (token, user) => request(app).post(`/api/invites/${token}/redeem`).set('x-user', user.id).send({}); +const stats = () => request(app).get('/api/admin/analytics/invites?days=30').set('x-user', staff.id).set('x-role', 'admin'); + +test('mint binds the cohort to the authenticated inviter and normalizes the UI source', async () => { + const response = await mint({ source: 'first-agent-post', createdBy: bob.id, inviterFirstSessionAt: '1900-01-01' }); + expect(response.status).toBe(201); + const invite = await PodInvite.findOne({ token: response.body.token }).lean(); + expect(String(invite.createdBy)).toBe(alice.id); + expect(invite.source).toBe('first-agent-post'); + const links = await request(app).get(`/api/pods/${pod.id}/invites`).set('x-user', alice.id).expect(200); + expect(links.body).toEqual(expect.arrayContaining([expect.objectContaining({ + token: response.body.token, source: 'first-agent-post', createdBy: expect.objectContaining({ _id: alice.id }), + })])); + expect(invite.inviterFirstSessionAt.toISOString()).toBe('2026-10-20T12:00:00.000Z'); + const unknown = await mint({ source: 'not-a-placement' }); + expect((await PodInvite.findOne({ token: unknown.body.token })).source).toBe('pod-menu'); + expect((await mint({}, bob)).status).toBe(403); +}); +test('one outside teammate converts once; self uses, repeats and staff do not inflate it', async () => { + const response = await mint({ source: 'scout-first-session' }); + const token = response.body.token; + await redeem(token, alice).expect(200); + await redeem(token, staff).expect(200); + expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toBeUndefined(); + await redeem(token, bob).expect(200); + const first = (await PodInvite.findOne({ token })).firstTeammateJoinedAt; + expect(first).toBeInstanceOf(Date); + await redeem(token, bob).expect(200); + expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toEqual(first); + const report = await stats().expect(200); + expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 1, inviters: 1, convertedInviters: 1 }]); + expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 1 }); + expect(JSON.stringify(report.body)).not.toContain(token); + expect(JSON.stringify(report.body)).not.toContain(alice.id); +}); +test('analytics excludes staff inviters and admits an explicit unobserved cohort', async () => { + await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: staff._id } }); + await mint({}, staff).expect(201); + await User.updateOne({ _id: alice._id }, { $unset: { 'onboarding.podOpenedAt': '' } }); + await mint({ source: 'solo-pod' }).expect(201); + const report = await stats().expect(200); + expect(report.body.cohorts).toEqual([{ firstSessionDay: null, created: 1, used: 0, inviters: 1, convertedInviters: 0 }]); + expect(report.body.sources['pod-menu']).toBeUndefined(); + await request(app).get('/api/admin/analytics/invites').set('x-user', alice.id).expect(403); + await request(app).get('/api/admin/analytics/invites').expect(401); +}); + +test('bot redemption does not count as a new outside teammate', async () => { + const bot = await User.create({ username: 'bot-cohort', email: 'bot@outside-company.org', password: 'test-password-123', isBot: true }); + const response = await mint({ source: 'scout-first-session' }); + await redeem(response.body.token, bot).expect(200); + expect((await PodInvite.findOne({ token: response.body.token })).firstTeammateJoinedAt).toBeUndefined(); + const report = await stats().expect(200); + expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 0 }); +}); +test('invites outside the creation window contribute to neither cohorts nor sources', async () => { + await PodInvite.create({ token: 'f'.repeat(32), podId: pod.id, createdBy: alice.id, + source: 'pod-menu', createdAt: new Date(Date.now() - 31 * 86400000), + inviterFirstSessionAt: new Date('2026-09-01T12:00:00Z'), firstTeammateJoinedAt: new Date(), + }); + await mint({ source: 'solo-pod' }).expect(201); + const report = await stats().expect(200); + expect(report.body.sources).toEqual({ 'solo-pod': { created: 1, used: 0 } }); + expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 0, inviters: 1, convertedInviters: 0 }]); +}); diff --git a/backend/models/PodInvite.ts b/backend/models/PodInvite.ts index 42c678d818..da93950e3e 100644 --- a/backend/models/PodInvite.ts +++ b/backend/models/PodInvite.ts @@ -1,5 +1,7 @@ import mongoose, { Schema, Document, Types, Model } from 'mongoose'; +export const INVITE_SOURCES = ['pod-menu', 'first-agent-post', 'scout-first-session', 'solo-pod'] as const; + export interface IPodInvite extends Document { token: string; podId: Types.ObjectId; @@ -9,6 +11,11 @@ export interface IPodInvite extends Document { useCount: number; revokedAt?: Date | null; lastUsedAt?: Date | null; + source?: typeof INVITE_SOURCES[number]; + /** Snapshot of the inviter's first observed workspace session, never client-set. */ + inviterFirstSessionAt?: Date; + /** First distinct outside human joining; repeated uses and bots never set it. */ + firstTeammateJoinedAt?: Date; createdAt: Date; updatedAt: Date; isUsable(): boolean; @@ -24,6 +31,9 @@ const PodInviteSchema = new Schema( useCount: { type: Number, default: 0 }, revokedAt: { type: Date, default: null }, lastUsedAt: { type: Date, default: null }, + source: { type: String, enum: INVITE_SOURCES }, + inviterFirstSessionAt: { type: Date }, + firstTeammateJoinedAt: { type: Date }, }, { timestamps: true }, ); diff --git a/backend/routes/admin/analytics.ts b/backend/routes/admin/analytics.ts index 5c305f46ee..93b209c4e9 100644 --- a/backend/routes/admin/analytics.ts +++ b/backend/routes/admin/analytics.ts @@ -55,6 +55,18 @@ const pgUserIdsWithMessages = async (userIds: string[]): Promise> => return new Set(result.rows.map((r: { user_id: string }) => String(r.user_id))); }; +// Invite creation -> first distinct outside teammate, by inviter session cohort. +router.get('/invites', adminReadLimiter, auth, adminAuth, async (req: any, res: any) => { + try { + const days = Math.max(1, Math.min(90, parseInt(req.query.days, 10) || 30)); + const { readInviteFunnel } = require('../../services/inviteFunnelService'); + return res.json(await readInviteFunnel(days)); + } catch (error) { + console.error('[invite-funnel] read failed:', (error as Error).message); + return res.status(503).json({ error: 'invite_funnel_unavailable' }); + } +}); + // GET /api/admin/analytics/funnel?days=30 // Daily signup cohorts with: signups, attached ≥1 agent, sent ≥1 message, // returned D1/D7 (proxy), plus a totals row with rates. diff --git a/backend/routes/podInvites.ts b/backend/routes/podInvites.ts index b87f15557d..38802649fc 100644 --- a/backend/routes/podInvites.ts +++ b/backend/routes/podInvites.ts @@ -14,7 +14,8 @@ import { assertInstalledAgentMayJoinPod } from '../services/outsiderGrantAgentJo const router = express.Router(); const auth = require('../middleware/auth'); const Pod = require('../models/Pod'); -const { PodInvite } = require('../models/PodInvite'); +const { PodInvite, INVITE_SOURCES } = require('../models/PodInvite'); +const { HUMAN_FILTER, internalAccountExclusion } = require('../services/userClassification'); const getUserId = (req: any) => req.userId || req.user?.id || req.user?._id; @@ -65,6 +66,16 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any, return res.status(403).json({ msg: 'Only pod members can create invites' }); } const { expiresInHours, maxUses } = req.body || {}; + // Attribution is optional telemetry. Old clients still mint the same link. + // Source is a closed UI label; the cohort is always read from User. + const source = INVITE_SOURCES.find((candidate: string) => candidate === req.body?.source) || 'pod-menu'; + let inviterFirstSessionAt: Date | undefined; + try { + const inviter = await User.findById(userId).select('onboarding.podOpenedAt').lean(); + inviterFirstSessionAt = inviter?.onboarding?.podOpenedAt || undefined; + } catch (error) { + console.warn('[invite-funnel] cohort read failed:', (error as Error).message); + } const token = crypto.randomBytes(16).toString('hex'); const expiresAt = expiresInHours && Number(expiresInHours) > 0 ? new Date(Date.now() + Number(expiresInHours) * 3600 * 1000) @@ -73,6 +84,8 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any, token, podId: pod._id, createdBy: userId, + source, + ...(inviterFirstSessionAt ? { inviterFirstSessionAt } : {}), expiresAt, maxUses: maxUses && Number(maxUses) > 0 ? Number(maxUses) : null, }); @@ -117,6 +130,7 @@ router.get('/pods/:podId/invites', inviteReadRateLimit, auth, async (req: any, r expiresAt: invite.expiresAt, maxUses: invite.maxUses, uses: Number(invite.useCount) || 0, + source: invite.source || null, }))); } catch (err: any) { console.error('List invites failed:', err.message); @@ -268,6 +282,22 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an pod.members.push(userId); pod.updatedAt = new Date(); await pod.save(); + // A token use by its author, an existing member, a bot or an internal + // account is not the outside team's second-person conversion. + if (String(invite.createdBy) !== String(userId)) { + try { + const outsidePerson = await User.exists({ + _id: userId, ...HUMAN_FILTER, ...internalAccountExclusion(), + }); + if (outsidePerson) await PodInvite.updateOne({ + _id: invite._id, firstTeammateJoinedAt: { $exists: false }, + }, { $set: { firstTeammateJoinedAt: new Date() } }); + } catch (error) { + // Membership has already been committed. Telemetry cannot undo a + // successful join or turn it into a misleading client error. + console.warn('[invite-funnel] conversion write failed:', (error as Error).message); + } + } } invite.useCount += 1; invite.lastUsedAt = new Date(); diff --git a/backend/services/inviteFunnelService.ts b/backend/services/inviteFunnelService.ts new file mode 100644 index 0000000000..94d9397db6 --- /dev/null +++ b/backend/services/inviteFunnelService.ts @@ -0,0 +1,44 @@ +import User from '../models/User'; +import { PodInvite, INVITE_SOURCES } from '../models/PodInvite'; +import { HUMAN_FILTER, internalAccountExclusion } from './userClassification'; + +/** Aggregate counts only: token material and people never leave this service. */ +export async function readInviteFunnel(days: number) { + const since = new Date(Date.now() - days * 86400000); + const invites = await PodInvite.find({ createdAt: { $gte: since } }) + .select('createdBy source inviterFirstSessionAt firstTeammateJoinedAt').lean(); + const people = await User.find({ + _id: { $in: [...new Set(invites.map((invite) => String(invite.createdBy)))] }, + ...HUMAN_FILTER, ...internalAccountExclusion(), + }).select('_id').lean(); + const outside = new Set(people.map((person) => String(person._id))); + const cohorts = new Map; convertedInviters: Set }>(); + const sources: Record = {}; + for (const invite of invites) { + const inviterId = String(invite.createdBy); + if (!outside.has(inviterId)) continue; + const day = invite.inviterFirstSessionAt?.toISOString().slice(0, 10) || null; + const key = day || 'unobserved'; + const cohort = cohorts.get(key) || { firstSessionDay: day, created: 0, used: 0, + inviters: new Set(), convertedInviters: new Set() }; + cohort.created += 1; + cohort.inviters.add(inviterId); + if (invite.firstTeammateJoinedAt) { cohort.used += 1; cohort.convertedInviters.add(inviterId); } + cohorts.set(key, cohort); + const source = INVITE_SOURCES.find((candidate) => candidate === invite.source) || 'unattributed'; + sources[source] ||= { created: 0, used: 0 }; + sources[source].created += 1; + if (invite.firstTeammateJoinedAt) sources[source].used += 1; + } + return { + days, createdSince: since.toISOString(), cohortBasis: 'inviter-first-observed-pod-open', + // Legacy useCount counts even an existing member's repeated redemption. + // The new marker measures new outside-human joins, forward from deploy. + usageMeasuredFromDeploy: true, + cohorts: [...cohorts.values()].sort((a, b) => (a.firstSessionDay || '').localeCompare(b.firstSessionDay || '')) + .map(({ inviters, convertedInviters, ...cohort }) => ({ ...cohort, + inviters: inviters.size, convertedInviters: convertedInviters.size })), + sources, + }; +} diff --git a/docs/development/invite-funnel.md b/docs/development/invite-funnel.md new file mode 100644 index 0000000000..711cbe1908 --- /dev/null +++ b/docs/development/invite-funnel.md @@ -0,0 +1,48 @@ +# The second-person invite moment + +`V2BringTeammate` uses the existing pod invite model and member-authorized API. +It appears for the sole human in a group pod, after that person's first agent +reply, in the owned Scout workspace, or in the ordinary empty state. An +incomplete history cannot establish the first reply. Direct-message pod types +are excluded. Membership changes remove the prompt when a second human joins. + +An impression performs only GET. A press reuses this user's usable link for the +same placement, or creates one with a seven-day expiry. Clipboard refusal keeps +and selects that link; retry never creates another token. Returning links are +shown without claiming the new browser has copied them. Dismissal is local to +the pod and placement. The just-created-pod starter also creates only on press. + +The automatic first-reply card is suppressed in the viewer-owned My Workspace +to avoid prompting them to share a private Scout conversation. The group-pod +reply and one-person empty-state placements remain available. + +Placement sources are `first-agent-post`, `scout-first-session`, and `solo-pod`; +`scout-first-session` is retained for historical attribution. Legacy/menu writes +default to `pod-menu`. Source is client-provided attribution, +not an authorization claim. Inviter identity and cohort time are server-bound. + +## Measurement + +Admin-only `GET /api/admin/analytics/invites?days=30` returns aggregate counts, +with a one-to-90-day window. It excludes internal and bot inviters using the +shared classification policy and never returns invite tokens or person IDs. + +- `created`: successful invite rows, counted by creation time and source. +- `used`: the first **new outside human** joining with that invite. Self uses, + already-member repeats, staff and bots do not convert a team. The existing + raw `useCount` still counts redemptions and is unchanged. +- `inviters` / `convertedInviters`: distinct outside inviters in each cohort. +- Cohort: UTC day of the inviter's immutable `onboarding.podOpenedAt`, copied at + mint. This is the first observed opening of a pod they created, not a claim + about an unobserved authentication session. Missing legacy marks are an + explicit `null` cohort. + +Usage measurement starts at deployment: legacy redemption counters are not +backfilled as teammate joins. Telemetry failures do not undo a successful mint +or membership join. The endpoint reports the forward-only limitation explicitly. + +The inline card's request state is owned above `V2ThreadMessages`; its view can +move between collapsed, visible and hidden-prefix reply slots without cancelling +a pending press. The owner renders no wrapper DOM, and remains inert until the +first-reply moment qualifies. Pod, viewer and placement still key its lifetime. +Settled reply views match the original source message ID, not the ruling's ID. diff --git a/frontend/design-system/preview/components-bring-teammate.html b/frontend/design-system/preview/components-bring-teammate.html new file mode 100644 index 0000000000..590fcbcb51 --- /dev/null +++ b/frontend/design-system/preview/components-bring-teammate.html @@ -0,0 +1,4 @@ +Bring a teammate · Signal

Bring a teammate

One card in three placements. Tokens and exact copy follow TASK-515. The interactive proof uses the production component; this page is the static component specimen.

+
only you here

Invite a teammate to Launch planning

They can work with Scout too. They'll see everything in this pod, including earlier messages.

+
only you here

Invite a teammate to My Workspace

Scout can split this work between you. They'll see this pod, including this conversation with Scout.

+
only you here

Invite a teammate to Launch planning

They can post here and work with the agents in this pod. They'll see everything posted here.

couldn't create a link · nothing was shared

diff --git a/frontend/design-system/tokens.css b/frontend/design-system/tokens.css index 25a39e3a02..1d546e62d6 100644 --- a/frontend/design-system/tokens.css +++ b/frontend/design-system/tokens.css @@ -53,6 +53,7 @@ --c-info: #0891b2; /* cyan — distinct from accent blue */ --c-info-soft: #dff4f8; --c-danger: #ef4444; + --c-danger-text: #b91c1c; --c-danger-soft: rgba(239, 68, 68, 0.10); /* ---------- Agent role accents (avatar tints, role chips) ---------- */ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a67c519851..aa1d279865 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2621,5 +2621,24 @@ "hours": "{{n}}h", "days": "{{n}}d" } + }, + "teammate": { + "kicker": "only you here", + "title": "Invite a teammate to {{podName}}", + "body": { + "agentPost": "They can work with {{agentName}} too. They'll see everything in this pod, including earlier messages.", + "scout": "Scout can split this work between you. They'll see this pod, including this conversation with Scout.", + "solo": "They can post here and work with the agents in this pod. They'll see everything posted here." + }, + "act": "Copy invite link", + "creating": "Creating link…", + "copied": "Copied", + "copyRetry": "Copy link", + "retry": "Try again", + "meta": "works for 7 days · anyone signed in with this link can join", + "clipboardFail": "couldn't copy automatically · the link is selected, copy it from here", + "error": "couldn't create a link · nothing was shared", + "live": "Invite link copied", + "dismiss": "Dismiss" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index b90d94af5e..1395badf2a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2612,5 +2612,24 @@ "hours": "{{n}}小时", "days": "{{n}}天" } + }, + "teammate": { + "kicker": "这里只有你", + "title": "邀请队友加入 {{podName}}", + "body": { + "agentPost": "队友也能和 {{agentName}} 一起工作,还能看到这个 Pod 里的全部内容,包括之前的消息。", + "scout": "Scout 可以把这些工作分给你们两人。他们能看到这个 Pod,包括你和 Scout 的这段对话。", + "solo": "队友可以在这里发消息,和这个 Pod 里的智能体一起工作,也能看到这里的全部内容,包括之前的消息。" + }, + "act": "复制邀请链接", + "creating": "正在生成链接…", + "copied": "已复制", + "copyRetry": "复制链接", + "retry": "重试", + "meta": "7天内有效 · 任何人登录后都能凭此链接加入", + "clipboardFail": "自动复制失败 · 链接已选中,请手动复制", + "error": "链接生成失败 · 未分享任何内容", + "live": "邀请链接已复制", + "dismiss": "关闭" } } diff --git a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx new file mode 100644 index 0000000000..8a188ae5a3 --- /dev/null +++ b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import V2BringTeammate from '../components/V2BringTeammate'; + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockWrite = jest.fn(); +const mockApi = { get: mockGet, post: mockPost }; +jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const token = 'a'.repeat(32); +const props = { podId: 'pod', podName: 'Work', userId: 'person', source: 'solo-pod' as const }; +const press = () => fireEvent.click(screen.getByRole('button', { name: 'teammate.act' })); +beforeEach(() => { + localStorage.clear(); + jest.resetAllMocks(); + mockGet.mockResolvedValue([]); + mockPost.mockResolvedValue({ token }); + mockWrite.mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: mockWrite } }); +}); +test('render only reads; a press creates one seven-day link and copies it', async () => { + render(); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1)); + expect(mockPost).not.toHaveBeenCalled(); + const button = screen.getByRole('button', { name: 'teammate.act' }); + fireEvent.click(button); fireEvent.click(button); + await screen.findByText('teammate.live'); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledWith('/api/pods/pod/invites', { expiresInHours: 168, maxUses: null, source: 'solo-pod' }); + expect(mockWrite).toHaveBeenCalledWith(`${window.location.origin}/v2/invite/${token}`); +}); +test('returning reuses only this user’s usable link from this placement without claiming it was copied', async () => { + mockGet.mockResolvedValue([ + { token: 'b'.repeat(32), source: 'solo-pod', createdBy: { _id: 'other' } }, + { token: 'c'.repeat(32), source: 'solo-pod', createdBy: 'person', maxUses: 0, uses: 0 }, + { token: 'd'.repeat(32), source: 'solo-pod', createdBy: 'person', expiresAt: '2020-01-01T00:00:00Z' }, + { token, source: 'solo-pod', createdBy: { _id: 'person' }, expiresAt: new Date(Date.now() + 60000).toISOString() }, + ]); + render(); + await screen.findByDisplayValue(`${window.location.origin}/v2/invite/${token}`); + expect(mockWrite).not.toHaveBeenCalled(); + expect(screen.queryByText('teammate.copied')).not.toBeInTheDocument(); + press(); + await screen.findByText('teammate.live'); + expect(mockPost).not.toHaveBeenCalled(); +}); +test('clipboard refusal selects the retained link, and retry never mints again', async () => { + mockWrite.mockRejectedValue(new Error('clipboard denied')); + render(); + press(); + const field = await screen.findByRole('textbox'); + await waitFor(() => expect(field).toHaveFocus()); + expect((field as HTMLInputElement).selectionEnd).toBe((field as HTMLInputElement).value.length); + mockWrite.mockResolvedValue(undefined); + fireEvent.click(screen.getByRole('button', { name: 'teammate.copyRetry' })); + await screen.findByText('teammate.live'); + expect(mockPost).toHaveBeenCalledTimes(1); +}); +test('failed POST is explicit; retry recovers an accepted link through GET', async () => { + mockPost.mockRejectedValue(new Error('lost response')); + render(); + press(); + await screen.findByRole('alert'); + mockGet.mockResolvedValue([{ token, source: 'solo-pod', createdBy: 'person' }]); + fireEvent.click(screen.getByRole('button', { name: 'teammate.retry' })); + await screen.findByText('teammate.live'); + expect(mockPost).toHaveBeenCalledTimes(1); +}); +test('a failed existing-link read does not permit blind minting', async () => { + mockGet.mockRejectedValue(new Error('offline')); + render(); + press(); + await screen.findByRole('alert'); + expect(mockPost).not.toHaveBeenCalled(); +}); +test('dismissal is scoped to pod and placement and cancels a pending pre-mint lookup', async () => { + let resolve!: (rows: unknown[]) => void; + mockGet.mockReturnValue(new Promise((done) => { resolve = done; })); + const { unmount } = render(); + press(); + fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' })); + await act(async () => { resolve([]); }); + expect(mockPost).not.toHaveBeenCalled(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + + +test('changing pods cannot carry a prior invite or late clipboard result into the next pod', async () => { + let finish!: () => void; + mockWrite.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const view = render(); + press(); + await waitFor(() => expect(mockWrite).toHaveBeenCalledTimes(1)); + view.rerender(); + await act(async () => { finish(); }); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByText('teammate.live')).not.toBeInTheDocument(); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledWith('/api/pods/different/invites'); +}); + +test('a retry shares its read across visibility changes and retains the new link', async () => { + let finish!: (rows: unknown[]) => void; + mockGet.mockRejectedValueOnce(new Error('offline')) + .mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + const view = render(); + await act(async () => { await Promise.resolve(); }); + press(); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2)); + view.rerender(); + view.rerender(); + await act(async () => { finish([]); }); + await screen.findByText('teammate.live'); + expect(mockGet).toHaveBeenCalledTimes(2); + expect(mockPost).toHaveBeenCalledTimes(1); + expect(mockWrite).toHaveBeenCalledTimes(1); + expect(screen.getByRole('textbox')).toHaveValue(`${window.location.origin}/v2/invite/${token}`); +}); diff --git a/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx index 31694c083b..b9779f4a02 100644 --- a/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadRestyle.test.tsx @@ -198,6 +198,26 @@ describe('direction C threading restyle (PR 2b)', () => { expect(container.querySelector('.v2-thread-block--open')).toBeNull(); }); + test('the first-reply invite survives collapse and the hidden expanded prefix exactly once', () => { + const { container } = renderThread({ afterMessage: { messageId: '21', content:
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' }); @@ -210,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 abe7129411..44f84613fd 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' }], @@ -316,6 +323,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 +335,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 +451,84 @@ 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.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 }; + 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('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('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); + 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 () => { + 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/__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__/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/__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/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 5401363fe2..d734780cb4 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4996,6 +4996,26 @@ test('TASK-435 runtime columns can shrink on phones without dropping the observe 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%'); +}); + +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;/); +}); + describe('run trace layout', () => { const css = read('../components/V2RunTrace.css'); test('long attribution IDs wrap, phone records stack, and links meet touch height', () => { diff --git a/frontend/src/v2/components/V2BringTeammate.css b/frontend/src/v2/components/V2BringTeammate.css new file mode 100644 index 0000000000..5fa57cd540 --- /dev/null +++ b/frontend/src/v2/components/V2BringTeammate.css @@ -0,0 +1,25 @@ +.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; margin: -1px; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@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..762c984a03 --- /dev/null +++ b/frontend/src/v2/components/V2BringTeammate.tsx @@ -0,0 +1,163 @@ +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; + 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, enabled = true, children }) => { + 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(() => { + generation.current += 1; + active.current = !dismissed; + lookup.current = null; + return () => { + active.current = false; + clearTimeout(copiedTimer.current); + }; + }, [dismissed, readExisting]); + + useEffect(() => { + if (!enabled || dismissed || lookup.current) return; + const epoch = generation.current; + 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 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(() => { + 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) { + 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`, { + 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); + } + }; + + 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'; + const card = enabled && !dismissed ? ( +
+
{t('teammate.kicker')}
+

{t('teammate.title', { podName })}

+

{t(`teammate.body.${body}`, { agentName })}

+
+ + +
+ {url &&
+ event.currentTarget.select()} /> +

{t(state === 'manual' ? 'teammate.clipboardFail' : 'teammate.meta')}

+
} + {state === 'error' &&

{t('teammate.error')}

} + {state === 'copied' ? t('teammate.live') : ''} +
+ ) : null; + return children ? <>{children(card)} : card; +}; +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 748c82ed1b..8be18059a3 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, @@ -46,6 +48,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 @@ -276,7 +279,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/ @@ -648,7 +650,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); @@ -684,19 +685,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); } @@ -788,21 +790,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 @@ -1434,6 +1432,18 @@ 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 || ''); + // 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 && !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'); + // 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); @@ -1755,7 +1765,11 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in {firstTaskError &&
{firstTaskError}
}
- + {(teammateCard) => = ({ 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} @@ -1821,7 +1836,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 ? ( @@ -1852,8 +1867,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 && detail.initialMessagesLoaded && } + )}
) : undefined} @@ -1862,7 +1880,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in onAddAgent={onOpenInvite ? () => onOpenInvite(AGENT_INVITE_TAB) : undefined} messagesContainerRef={messagesContainerRef} messagesEndRef={messagesEndRef} - /> + />} +
{ void retryHistorySearch(); }} diff --git a/frontend/src/v2/components/V2ThreadMessages.tsx b/frontend/src/v2/components/V2ThreadMessages.tsx index 8f849bca8c..31e8d54f8e 100644 --- a/frontend/src/v2/components/V2ThreadMessages.tsx +++ b/frontend/src/v2/components/V2ThreadMessages.tsx @@ -59,6 +59,7 @@ interface V2ThreadMessagesProps { error: string | null; starterPanel?: React.ReactNode; emptyState?: React.ReactNode; + afterMessage?: { messageId: string; content: React.ReactNode }; agentDeliveryHint?: { messageId: string; mentionHandle: string } | null; // A pod with no installed agent answers nothing by construction, so there is // no handle to suggest — a different row, in the same slot. @@ -150,6 +151,7 @@ const V2ThreadMessages: React.FC = ({ error, starterPanel, emptyState, + afterMessage, agentDeliveryHint, noAgentsHint, onAddAgent, @@ -331,6 +333,7 @@ const V2ThreadMessages: React.FC = ({ )}

)} + {afterMessage?.messageId === String(message.id) && afterMessage.content} {agentDeliveryHint?.messageId === message.id && (
= ({ ? 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 ( @@ -404,9 +409,10 @@ const V2ThreadMessages: React.FC = ({ {t('podChat.thread.moreReplies', { count: hiddenReplies })} )} + {hiddenHasAfter && afterMessage?.content} {renderedReplyEntries(shownReplies).map(({ sourceId, message: reply, ruling }, replyIndex, renderedReplies) => ( + = ({ grouped={isGroupedWithPrevious(reply, renderedReplies[replyIndex - 1]?.message)} insideThreadRoot={item.rootId} /> + {afterMessage?.messageId === sourceId && afterMessage.content} + ))}
)}
+ {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 && } {inviteUrl && (
{ // 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); @@ -344,6 +347,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { setError(null); setSendError(null); setInitialLoadComplete(false); + setInitialMessagesLoaded(false); setHasMore(false); setLoadingOlder(false); updateHistorySearch({ @@ -369,6 +373,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)); + // 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. @@ -596,6 +602,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { loadingOlderRef.current = false; setLoadingOlder(false); setInitialLoadComplete(false); + setInitialMessagesLoaded(false); if (!podId) { setPod(null); setMessages([]); @@ -803,6 +810,7 @@ export const useV2PodDetail = (podId: string | null): UseV2PodDetailResult => { agents, loading, initialLoadComplete, + initialMessagesLoaded, error, sendError, hasMore, diff --git a/frontend/src/v2/utils/teammateInviteMoment.ts b/frontend/src/v2/utils/teammateInviteMoment.ts new file mode 100644 index 0000000000..9c68ed9ddd --- /dev/null +++ b/frontend/src/v2/utils/teammateInviteMoment.ts @@ -0,0 +1,32 @@ +import type { V2Message } from '../hooks/useV2PodDetail'; +import type { V2Pod, V2PodMember } from '../hooks/useV2Pods'; +import type { TeammateInviteSource } from '../components/V2BringTeammate'; + +export function isSoloHumanPod(pod: V2Pod, members: V2PodMember[], viewerId: string) { + if (!viewerId || ['agent-admin', 'agent-room', 'agent-dm'].includes(pod.type || '')) return false; + // Unknown member types count as human: incomplete metadata must not claim + // that the viewer is alone in a pod containing another person. + const humans = members.filter((member) => 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 56bca25a64..00c4572b86 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 */ @@ -2473,6 +2474,14 @@ body.modern-ui.v2-canvas { font-weight: 700; } +.v2-chat__new-pod-action--invite > .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); }