diff --git a/docs/audits/scout-connect-byo-2026-10-27/after-1440.png b/docs/audits/scout-connect-byo-2026-10-27/after-1440.png new file mode 100644 index 0000000000..1777f984f9 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/after-1440.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/after-390.png b/docs/audits/scout-connect-byo-2026-10-27/after-390.png new file mode 100644 index 0000000000..d2846316a6 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/after-390.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/before-1440.png b/docs/audits/scout-connect-byo-2026-10-27/before-1440.png new file mode 100644 index 0000000000..d168890cb0 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/before-1440.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/before-390.png b/docs/audits/scout-connect-byo-2026-10-27/before-390.png new file mode 100644 index 0000000000..e2b48bf945 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/before-390.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/card-after-1440.png b/docs/audits/scout-connect-byo-2026-10-27/card-after-1440.png new file mode 100644 index 0000000000..11c27b1aef Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/card-after-1440.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/card-after-390.png b/docs/audits/scout-connect-byo-2026-10-27/card-after-390.png new file mode 100644 index 0000000000..932569c168 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/card-after-390.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/card-before-1440.png b/docs/audits/scout-connect-byo-2026-10-27/card-before-1440.png new file mode 100644 index 0000000000..24a694c47c Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/card-before-1440.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/card-before-390.png b/docs/audits/scout-connect-byo-2026-10-27/card-before-390.png new file mode 100644 index 0000000000..84af52aa41 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/card-before-390.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/nonowner-1440.png b/docs/audits/scout-connect-byo-2026-10-27/nonowner-1440.png new file mode 100644 index 0000000000..09806749f9 Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/nonowner-1440.png differ diff --git a/docs/audits/scout-connect-byo-2026-10-27/nonowner-390.png b/docs/audits/scout-connect-byo-2026-10-27/nonowner-390.png new file mode 100644 index 0000000000..2f61da6c8c Binary files /dev/null and b/docs/audits/scout-connect-byo-2026-10-27/nonowner-390.png differ diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3348b9eba5..8b0e44f711 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -556,7 +556,8 @@ "taskClaimedVerb": "claimed" }, "starts": { - "label": "Ways to start" + "label": "Ways to start", + "openByo": "Open Bring your own" }, "dmMark": "DM", "invite": "Invite to this pod", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 7fb4816699..5c5a8a12fb 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -556,7 +556,8 @@ "taskClaimedVerb": "认领了" }, "starts": { - "label": "开始方式" + "label": "开始方式", + "openByo": "去接入自己的智能体" }, "dmMark": "私信", "invite": "邀请成员加入这个 Pod", diff --git a/frontend/src/v2/__tests__/V2Composer.test.tsx b/frontend/src/v2/__tests__/V2Composer.test.tsx index d42b23a2da..d728f06ad2 100644 --- a/frontend/src/v2/__tests__/V2Composer.test.tsx +++ b/frontend/src/v2/__tests__/V2Composer.test.tsx @@ -22,9 +22,11 @@ jest.mock('../components/V2Avatar', () => { }); jest.mock('../utils/avatars', () => ({ initialsFor: (name: string) => name.slice(0, 2) })); -// jsdom has no scrollIntoView; the component auto-scrolls on mount. +// jsdom has neither scrollIntoView nor Element.scrollTo. The thread follows by +// scrolling its message list (scrollTo), so `scrollTo` is the follow signal. beforeAll(() => { Element.prototype.scrollIntoView = jest.fn(); + Element.prototype.scrollTo = jest.fn(); }); // Same mock surface as V2Login.test.tsx — axiosConfig assigns @@ -452,19 +454,22 @@ describe('V2Composer send button', () => { clientHeight: { configurable: true, value: 400 }, scrollTop: { configurable: true, writable: true, value: 0 }, }); + // The reader's own input: without it, a scroll inside a follow's + // animation window is the follow, not the reader. + fireEvent.wheel(scroller); fireEvent.scroll(scroller); return scroller; }; beforeEach(() => { - (Element.prototype.scrollIntoView as jest.Mock).mockClear(); + (Element.prototype.scrollTo as jest.Mock).mockClear(); }); test('background arrivals stay put even when another tab uses the same user', async () => { const detail = makeDetail({ messages: [transcriptMessage('start')] }); const view = renderChat(detail); const scroller = scrollUp(view); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); view.rerender( @@ -522,7 +527,7 @@ describe('V2Composer send button', () => { }); const view = renderChat(detail); scrollUp(view); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); fireEvent.change(composerInput(), { target: { value: 'sent from this tab' } }); @@ -556,7 +561,7 @@ describe('V2Composer send button', () => { }); const view = renderChat(detail); scrollUp(view); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); fireEvent.change(composerInput(), { target: { value: 'post first' } }); @@ -585,7 +590,7 @@ describe('V2Composer send button', () => { }); const view = renderChat(detail); const scroller = scrollUp(view); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); fireEvent.change(composerInput(), { target: { value: 'will fail' } }); @@ -617,7 +622,7 @@ describe('V2Composer send button', () => { }); const view = renderChat(detail); scrollUp(view); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); upload(view.container); @@ -646,7 +651,7 @@ describe('V2Composer send button', () => { fireEvent.change(composerInput(), { target: { value: 'old own' } }); fireEvent.click(screen.getByRole('button', { name: /send message/i })); await waitFor(() => expect(oldDetail.sendMessage).toHaveBeenCalledTimes(1)); - const scrollIntoView = Element.prototype.scrollIntoView as jest.Mock; + const scrollIntoView = Element.prototype.scrollTo as jest.Mock; scrollIntoView.mockClear(); const newDetail = { @@ -668,6 +673,7 @@ describe('V2Composer send button', () => { const scroller = scrollUp(view); scrollIntoView.mockClear(); scroller.scrollTop = 0; + fireEvent.wheel(scroller); fireEvent.scroll(scroller); view.rerender( diff --git a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx index 44f84613fd..3042133474 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarter.test.tsx @@ -24,6 +24,7 @@ jest.mock('../components/V2MessageRow', () => { // jsdom has no scrollIntoView; the component auto-scrolls on mount. beforeAll(() => { Element.prototype.scrollIntoView = jest.fn(); + Element.prototype.scrollTo = jest.fn(); Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) }, @@ -523,6 +524,76 @@ describe('second-person prompt placements', () => { expect(axios.post).toHaveBeenCalledTimes(1); }); + // TASK-583: on a reload the card mounts after history settles, below where + // the arrival effect already followed. A follow scrolls the message list + // itself; scrollIntoView would also scroll the overflow-hidden main pane and + // take the whole thread off screen (UX Lead on #2870), so it must never run. + const endFollows = () => Element.prototype.scrollTo.mock.contexts + .filter((el) => el.classList.contains('v2-chat__messages')).length; + const clearScrolls = () => { + Element.prototype.scrollTo.mockClear(); + Element.prototype.scrollIntoView.mockClear(); + }; + const rerenderChat = (view, detail) => view.rerender( + + ); + // `byReader` fires the reader's own input first. Without it, the scroll is + // treated as part of the app's smooth follow, as in a real browser. + const scrollListTo = (scrollTop, { byReader = true } = {}) => { + const list = document.querySelector('.v2-chat__messages'); + Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 2000 }); + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 400 }); + if (byReader) fireEvent.wheel(list); + list.scrollTop = scrollTop; + fireEvent.scroll(list); + }; + test('when the invite card mounts with the reader at the end, the thread follows it into view', async () => { + const settled = withReply(); + const view = renderChat({ ...settled, hasMore: true }); + expect(document.querySelector('.v2-teammate')).toBeNull(); + scrollListTo(1600); + clearScrolls(); + rerenderChat(view, settled); + expect(await screen.findByRole('button', { name: 'Copy invite link' })).toBeInTheDocument(); + expect(endFollows()).toBe(1); + expect(Element.prototype.scrollTo.mock.calls.at(-1)[0]).toEqual({ top: 2000, behavior: 'smooth' }); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + // UX Lead on e0fd1523: the open-time follow is a smooth scroll whose own + // mid-animation scroll events read as the reader scrolling up, so a card + // mounting after the first of them was skipped and the list stopped one card + // short. 6 of 18 opens at 1440. + test('a card that mounts while a follow is still animating is still followed', async () => { + const settled = withReply(); + const arrived = { ...settled, messages: [...settled.messages, { + id: '12', pod_id: 'p1', user_id: 'bot', content: 'And a second step', message_type: 'text', + created_at: '2026-10-25T10:02:00Z', user: { username: 'scout', isBot: true }, + }] }; + const view = renderChat({ ...settled, hasMore: true }); + scrollListTo(1600); + clearScrolls(); + // An arrival at the end starts a smooth follow... + rerenderChat(view, { ...arrived, hasMore: true }); + expect(endFollows()).toBe(1); + clearScrolls(); + // ...whose first animation frame is still far from the end. That is the + // app scrolling, not the reader (no wheel/touch/key/pointer input). + scrollListTo(800, { byReader: false }); + rerenderChat(view, arrived); + expect(await screen.findByRole('button', { name: 'Copy invite link' })).toBeInTheDocument(); + expect(endFollows()).toBe(1); + }); + test('a reader scrolled up stays put when the invite card mounts', async () => { + const settled = withReply(); + const view = renderChat({ ...settled, hasMore: true }); + scrollListTo(100); + clearScrolls(); + rerenderChat(view, settled); + expect(await screen.findByRole('button', { name: 'Copy invite link' })).toBeInTheDocument(); + expect(endFollows()).toBe(0); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + test('dismissing the state-owned invite leaves the transcript and composer mounted', async () => { renderChat(withReply()); fireEvent.click(await screen.findByRole('button', { name: 'Dismiss' })); diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index 9e15f645ec..e1baa27546 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -178,19 +178,21 @@ describe("V2Thread — Scout's starts", () => { test('a start this person already sent stays gone after a reload', () => { renderThread([welcome, message('sent', 'u1', 'maya', CONNECT, 4)]); - const group = startsGroup(); + // The welcome's group comes first; the sent connect start carries its own. + const [group] = screen.getAllByRole('group', { name: 'Ways to start' }); expect(within(group).queryByRole('button', { name: CONNECT })).not.toBeInTheDocument(); expect(within(group).getByRole('button', { name: POD_START })).toBeInTheDocument(); }); - test('both starts sent: no group is left behind', () => { + test('both starts sent: only the Bring your own link is left', () => { renderThread([ welcome, message('sent-1', 'u1', 'maya', CONNECT, 4), message('sent-2', 'u1', 'maya', POD_START, 5), ]); - expect(startsGroup()).not.toBeInTheDocument(); + expect(within(startsGroup()).getAllByRole('button').map((button) => button.textContent)) + .toEqual([OPEN_BYO]); }); test('a message without starts shows no buttons, including an older welcome', () => { @@ -202,6 +204,50 @@ describe("V2Thread — Scout's starts", () => { }); }); +// D139: Scout's reply to the connect start is a prompt and cannot be relied on +// to link Bring your own, so the thread links it under the person's own +// message, from the moment it is sent. +const OPEN_BYO = 'Open Bring your own'; + +describe('V2Thread — the connect start links Bring your own', () => { + test("clicking the connect start shows the link under the person's message at once", async () => { + const send = renderThread([welcome]); + fireEvent.click(within(startsGroup()).getByRole('button', { name: CONNECT })); + expect(send).toHaveBeenCalledWith(CONNECT, 'text', undefined, undefined); + + const link = await screen.findByRole('button', { name: OPEN_BYO }); + fireEvent.click(link); + await waitFor(() => expect(screen.getByTestId('location').textContent).toBe('/v2/agents/byo?pod=pod-1')); + }); + + test('the link shows only under that exact label', () => { + renderThread([ + welcome, + message('close', 'u1', 'maya', 'Connect an agent I already use, please', 4), + message('lower', 'u1', 'maya', 'connect an agent i already use', 5), + message('pod', 'u1', 'maya', POD_START, 6), + ]); + expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); + }); + + test("another member's connect message gets no link", () => { + renderThread([welcome, message('theirs', 'u2', 'lee', CONNECT, 4)]); + expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); + }); + + test("in a pod the viewer didn't create, their own connect message gets no link", () => { + renderThread([welcome, message('mine', 'u1', 'maya', CONNECT, 4)], jest.fn(), { + pod: { ...OWN_POD, createdBy: 'someone-else' }, + }); + expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); + }); + + test('a message from Scout with that text gets no link', () => { + renderThread([message('echo', 'scout-1', 'scout', CONNECT, 4, { user: { username: 'scout', isBot: true } })]); + expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); + }); +}); + // Scout's first-task proposal is structured: the row is created only after a // person clicks Add to board, and Bring your own targets the new pod. const BYO_HREF = '/v2/agents/byo?pod=pod-1&name=maya-agent'; diff --git a/frontend/src/v2/components/V2BringTeammate.tsx b/frontend/src/v2/components/V2BringTeammate.tsx index 762c984a03..e43d14c608 100644 --- a/frontend/src/v2/components/V2BringTeammate.tsx +++ b/frontend/src/v2/components/V2BringTeammate.tsx @@ -19,12 +19,16 @@ interface Props { source: TeammateInviteSource; agentName?: string; enabled?: boolean; + /** Called when the card appears, so a thread at its end can keep the card in view. */ + onShown?: () => void; /** 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 BringTeammateContent: React.FC = ({ + podId, podName, userId, source, agentName, enabled = true, onShown, children, +}) => { const { t } = useTranslation(); const api = useV2Api(); const apiRef = useRef(api); @@ -129,7 +133,13 @@ const BringTeammateContent: React.FC = ({ podId, podName, userId, source, 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 ? ( + const shown = enabled && !dismissed; + const onShownRef = useRef(onShown); + onShownRef.current = onShown; + useEffect(() => { + if (shown) onShownRef.current?.(); + }, [shown]); + const card = shown ? (
{t('teammate.kicker')}

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

diff --git a/frontend/src/v2/components/V2Thread.tsx b/frontend/src/v2/components/V2Thread.tsx index bb916e3803..bf47c29133 100644 --- a/frontend/src/v2/components/V2Thread.tsx +++ b/frontend/src/v2/components/V2Thread.tsx @@ -40,6 +40,12 @@ import { slugifyAgentHandle, } from '../utils/threadAgentIdentity'; +// The connect start's label, as SCOUT_STARTS[0] sends it +// (backend/config/native-agents/scout.ts). A click posts exactly this text. +const CONNECT_START_LABEL = 'Connect an agent I already use'; +// How long a smooth follow may run before its scroll events count as the reader's. +const FOLLOW_SETTLE_MS = 1500; + const AGENT_DELIVERY_HINT_KEY = 'v2.agentDeliveryHint'; // Same slot, different fact, so a pod can show both over a session: the hint // above needs an agent to name, and this row exists precisely because there is @@ -433,8 +439,24 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in && !sentSince.has(start.label)); if (open.length > 0) byMessage.set(String(message.id), open); }); + // D139: under the creator's own "Connect an agent I already use", a fixed + // link to Bring your own, from the moment it is sent. Scout still answers, + // but a prompt cannot guarantee its reply links there, and one new person + // left after three replies that never did. + if (viewerOwnsPod && pod?._id) { + const openByo: V2Start = { + id: 'open-byo', + label: t('podChat.starts.openByo'), + href: `/v2/agents/byo?pod=${encodeURIComponent(pod._id)}`, + }; + messages.forEach((message) => { + if (message.user_id !== viewerId || (message.content || '').trim() !== CONNECT_START_LABEL) return; + const id = String(message.id); + byMessage.set(id, [...(byMessage.get(id) || []), openByo]); + }); + } return byMessage; - }, [messages, viewerId, onOpenInvite, viewerHoldsHostedAgent, viewerOwnsPod, addedFirstTaskMessages]); + }, [messages, viewerId, onOpenInvite, viewerHoldsHostedAgent, viewerOwnsPod, addedFirstTaskMessages, pod?._id, t]); const [decisions, setDecisions] = useState([]); const [settledDecisionByMessageId, setSettledDecisionByMessageId] = useState>(new Map()); @@ -817,30 +839,63 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in // The pill mounts once the reader is a viewport up; `· N` only with arrivals. const [scrolledUp, setScrolledUp] = useState(false); const edgeRef = useRef(null); + // Scroll the message list itself, never via scrollIntoView. scrollIntoView + // also scrolls every scrollable ancestor, and main.v2-pane is overflow + // hidden: when the list is already at its end the browser moves the pane + // instead, taking the header and thread off screen with no way back + // (UX Lead on #2870; the composer-send jump in TASK-584). + // + // A smooth follow fires its own scroll events on the way down. Those must + // not read as the reader scrolling up, or a card that mounts mid-animation + // is skipped and the list stops one card short (UX Lead on e0fd1523). So + // until the follow lands, only real input (wheel, touch, key, pointer) + // can take the reader off the bottom; a time limit backs that up. + const followingUntilRef = useRef(0); + const scrollListToEnd = useCallback(() => { + const el = messagesContainerRef.current; + if (!el) return; + followingUntilRef.current = Date.now() + FOLLOW_SETTLE_MS; + if (typeof el.scrollTo === 'function') el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + else el.scrollTop = el.scrollHeight; + }, []); const jumpToLatest = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + scrollListToEnd(); atBottomRef.current = true; setJumpCount(0); setScrolledUp(false); - }, []); + }, [scrollListToEnd]); + // The invite card can mount after the arrival effect has already followed + // (on a reload, once history settles), landing a card-height below the fold. + // Follow it only when the reader was at the end; never move someone reading up. + const followCardIfAtBottom = useCallback(() => { + if (atBottomRef.current) scrollListToEnd(); + }, [scrollListToEnd]); useEffect(() => { const el = messagesContainerRef.current; if (!el) return undefined; const onScroll = () => { const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const near = distance < 80; + if (near) followingUntilRef.current = 0; + else if (Date.now() < followingUntilRef.current) return; atBottomRef.current = near; setScrolledUp(distance > el.clientHeight); if (near) setJumpCount(0); }; + const onReaderInput = () => { followingUntilRef.current = 0; }; + const inputs = ['wheel', 'touchstart', 'keydown', 'pointerdown'] as const; onScroll(); el.addEventListener('scroll', onScroll, { passive: true }); - return () => el.removeEventListener('scroll', onScroll); + inputs.forEach((name) => el.addEventListener(name, onReaderInput, { passive: true })); + return () => { + el.removeEventListener('scroll', onScroll); + inputs.forEach((name) => el.removeEventListener(name, onReaderInput)); + }; }, [pod?._id]); useEffect(() => { if (!newestMessageId) return; if (atBottomRef.current) { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + scrollListToEnd(); setJumpCount(0); } else { setJumpCount((count) => count + 1); @@ -855,8 +910,9 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in useEffect(() => { if (!sendFollowVersion || !sentMessageIdRef.current) return; atBottomRef.current = true; - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + scrollListToEnd(); setJumpCount(0); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [sendFollowVersion]); const rememberSentMessage = useCallback((sendPodId: string, created: import('../hooks/useV2PodDetail').V2Message) => { @@ -1772,7 +1828,8 @@ const V2Thread: React.FC = ({ detail, firstRunVisible = false, in + enabled={Boolean(teammateMoment)} + onShown={followCardIfAtBottom}> {(teammateCard) =>