Skip to content
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,8 @@
"taskClaimedVerb": "认领了"
},
"starts": {
"label": "开始方式"
"label": "开始方式",
"openByo": "去接入自己的智能体"
},
"dmMark": "私信",
"invite": "邀请成员加入这个 Pod",
Expand Down
22 changes: 14 additions & 8 deletions frontend/src/v2/__tests__/V2Composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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' } });
Expand Down Expand Up @@ -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' } });
Expand Down Expand Up @@ -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' } });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand All @@ -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(
<AuthContext.Provider value={authValue}>
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/v2/__tests__/V2ThreadStarter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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(<AuthContext.Provider value={authValue}><MemoryRouter>
<V2Thread detail={detail} />
</MemoryRouter></AuthContext.Provider>);
// `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' }));
Expand Down
52 changes: 49 additions & 3 deletions frontend/src/v2/__tests__/V2ThreadStarts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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';
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/v2/components/V2BringTeammate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({ podId, podName, userId, source, agentName, enabled = true, children }) => {
const BringTeammateContent: React.FC<Props> = ({
podId, podName, userId, source, agentName, enabled = true, onShown, children,
}) => {
const { t } = useTranslation();
const api = useV2Api();
const apiRef = useRef(api);
Expand Down Expand Up @@ -129,7 +133,13 @@ const BringTeammateContent: React.FC<Props> = ({ 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 ? (
<section className={`v2-teammate${source === 'solo-pod' ? ' v2-teammate--empty' : ' v2-teammate--message'}`} aria-label={t('teammate.title', { podName })}>
<div className="v2-teammate__kicker">{t('teammate.kicker')}</div>
<h3>{t('teammate.title', { podName })}</h3>
Expand Down
Loading
Loading