diff --git a/apps/web/src/pages/ProjectGroupPage.tsx b/apps/web/src/pages/ProjectGroupPage.tsx index bd19f00..6524099 100644 --- a/apps/web/src/pages/ProjectGroupPage.tsx +++ b/apps/web/src/pages/ProjectGroupPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { flushSync } from "react-dom"; import { api } from "../api"; import { Link } from "../router"; @@ -7,9 +7,58 @@ import type { Agent, ProjectGroupMessage } from "../types"; import { EmptyState, ErrorState, Icon, LoadingPanel, PageHeader, Spinner, StatePill, formatTime, shortId } from "../ui"; import { MAX_PROJECT_GROUP_MESSAGE_LENGTH, + filterProjectGroupMentionTargets, + findProjectGroupMentionCandidates, prependProjectGroupMentionWithinLimit, + replaceProjectGroupMentionQuery, + type ProjectGroupMentionTarget, } from "./project-group-mention"; +const MENTION_SUGGESTIONS_ID = "group-message-mention-suggestions"; +const SCROLL_PINNED_TOLERANCE = 24; + +function isPinnedToMessageEnd(element: HTMLElement): boolean { + return element.scrollHeight - element.scrollTop - element.clientHeight <= SCROLL_PINNED_TOLERANCE; +} + +function projectGroupMessageSnapshot(message: ProjectGroupMessage): string { + return JSON.stringify([ + message.id, + message.projectId, + message.missionId, + message.senderType, + message.senderId, + message.content, + message.targetAgentId, + message.contractId, + message.contractAssignment ?? null, + message.createdAt, + ]); +} + +export function projectGroupMessagesMatch( + current: readonly ProjectGroupMessage[], + next: readonly ProjectGroupMessage[], +): boolean { + return current.length === next.length && current.every((message, index) => { + const candidate = next[index]; + return candidate !== undefined && projectGroupMessageSnapshot(message) === projectGroupMessageSnapshot(candidate); + }); +} + +export function resolveProjectGroupMessageRefresh( + current: ProjectGroupMessage[], + next: ProjectGroupMessage[], + options: { pinnedToMessageEnd: boolean; followAfterSend: boolean }, +): { messages: ProjectGroupMessage[]; shouldFollowMessageEnd: boolean } { + const changed = !projectGroupMessagesMatch(current, next); + return { + messages: changed ? next : current, + shouldFollowMessageEnd: options.followAfterSend + || (changed && (current.length === 0 || options.pinnedToMessageEnd)), + }; +} + function senderName(message: ProjectGroupMessage, agents: Agent[]): string { if (message.senderType === "human") return "You"; if (message.senderType === "shepherd") return "Shepherd"; @@ -56,11 +105,25 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { const [composerError, setComposerError] = useState(null); const [sending, setSending] = useState(false); const [initializing, setInitializing] = useState(false); + const [mentionCursor, setMentionCursor] = useState(0); + const [mentionActiveIndex, setMentionActiveIndex] = useState(0); + const [mentionDismissed, setMentionDismissed] = useState(false); + const messagesPane = useRef(null); const messageEnd = useRef(null); + const mentionSuggestions = useRef(null); const messageComposer = useRef(null); const inFlight = useRef(false); + const scrollToMessageEndOnChange = useRef(true); + const scrollAfterSend = useRef(false); + + const callableAgents = useMemo(() => filterProjectGroupMentionTargets(agents), [agents]); + const mentionCandidates = useMemo( + () => findProjectGroupMentionCandidates(content, mentionCursor, callableAgents), + [callableAgents, content, mentionCursor], + ); + const showMentionSuggestions = Boolean(project) && !sending && !mentionDismissed && mentionCandidates.length > 0; - const insertMention = (agent: Agent) => { + const insertMention = (agent: ProjectGroupMentionTarget) => { if (sending) return; const nextContent = prependProjectGroupMentionWithinLimit(agent.name, agent.id, content); flushSync(() => { @@ -69,6 +132,8 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { } else { setComposerError(null); setContent(nextContent); + setMentionCursor(nextContent.length); + setMentionDismissed(false); } }); const composer = messageComposer.current; @@ -82,7 +147,23 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { inFlight.current = true; try { const result = await api.groupMessages(project.id); - setMessages(result.messages); + const pinnedToMessageEnd = messagesPane.current + ? isPinnedToMessageEnd(messagesPane.current) + : true; + const followAfterSend = scrollAfterSend.current; + scrollAfterSend.current = false; + setMessages((current) => { + const refresh = resolveProjectGroupMessageRefresh(current, result.messages, { + pinnedToMessageEnd, + followAfterSend, + }); + if (refresh.messages === current) return current; + scrollToMessageEndOnChange.current = refresh.shouldFollowMessageEnd; + return refresh.messages; + }); + if (followAfterSend) { + window.requestAnimationFrame(() => messageEnd.current?.scrollIntoView({ block: "nearest" })); + } setMessageError(null); } catch (reason) { setMessageError(reason instanceof Error ? reason.message : "Project Group is unavailable"); @@ -103,10 +184,41 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { return () => window.clearInterval(interval); }, [project, refreshMessages]); - useEffect(() => { + useLayoutEffect(() => { + if (!scrollToMessageEndOnChange.current) return; messageEnd.current?.scrollIntoView({ block: "nearest" }); + scrollToMessageEndOnChange.current = false; }, [messages]); + useEffect(() => { + setMentionActiveIndex(0); + }, [content, mentionCursor]); + + useEffect(() => { + if (!showMentionSuggestions) return; + mentionSuggestions.current + ?.querySelector('[role="option"][aria-selected="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [mentionActiveIndex, mentionCandidates, showMentionSuggestions]); + + const selectMention = (agent: ProjectGroupMentionTarget) => { + const next = replaceProjectGroupMentionQuery(content, mentionCursor, agent); + if (!next) { + setComposerError("Mention would exceed the 2,000-character message limit."); + return; + } + flushSync(() => { + setContent(next.content); + setMentionCursor(next.selectionStart); + setMentionDismissed(true); + setComposerError(null); + }); + const composer = messageComposer.current; + if (!composer) return; + composer.focus(); + composer.setSelectionRange(next.selectionStart, next.selectionStart); + }; + const initializeGroup = async () => { if (initializing) return; setInitializing(true); @@ -139,6 +251,9 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { ...(nextContent.startsWith("@") ? { assignmentPreset: "auth-demo-contract" as const } : {}), }); setContent(""); + setMentionCursor(0); + setMentionDismissed(false); + scrollAfterSend.current = true; await Promise.all([refreshMessages(), refreshState()]); } catch (reason) { setMessageError(reason instanceof Error ? reason.message : "Message could not be routed"); @@ -160,7 +275,7 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) {
Route directly: - {agents.map((agent) => ( + {callableAgents.map((agent) => ( {messageError} · retrying automatically
) : null} -
+
{messagesLoading && messages.length === 0 ? : null} {!messagesLoading && sortedMessages.length === 0 ? ( { setContent(event.target.value); + setMentionCursor(event.target.selectionStart); + setMentionDismissed(false); setComposerError(null); }} + onSelect={(event) => setMentionCursor(event.currentTarget.selectionStart)} + onKeyUp={(event) => setMentionCursor(event.currentTarget.selectionStart)} onKeyDown={(event) => { + if (showMentionSuggestions) { + if (event.key === "ArrowDown") { + event.preventDefault(); + setMentionActiveIndex((current) => Math.min(current + 1, mentionCandidates.length - 1)); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setMentionActiveIndex((current) => Math.max(current - 1, 0)); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setMentionDismissed(true); + return; + } + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + const agent = mentionCandidates[mentionActiveIndex]; + if (agent) selectMention(agent); + return; + } + } if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); event.currentTarget.form?.requestSubmit(); @@ -227,7 +369,28 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { disabled={!project || sending} maxLength={MAX_PROJECT_GROUP_MESSAGE_LENGTH} aria-describedby={composerError ? "group-message-error" : undefined} + aria-autocomplete="list" + aria-controls={showMentionSuggestions ? MENTION_SUGGESTIONS_ID : undefined} + aria-expanded={showMentionSuggestions} + aria-activedescendant={showMentionSuggestions ? `${MENTION_SUGGESTIONS_ID}-${mentionActiveIndex}` : undefined} /> + {showMentionSuggestions ? ( +
+ {mentionCandidates.map((agent, index) => ( + + ))} +
+ ) : null}
{composerError ?? "Unmentioned → Shepherd · @AgentName → targeted contract"} diff --git a/apps/web/src/pages/project-group-mention.test.tsx b/apps/web/src/pages/project-group-mention.test.tsx index fc7fbe5..0a762a7 100644 --- a/apps/web/src/pages/project-group-mention.test.tsx +++ b/apps/web/src/pages/project-group-mention.test.tsx @@ -2,12 +2,20 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { parseProjectGroupMessage } from "../../../server/src/shepherd/group-routing.js"; -import { InitializeProjectGroupButton, ProjectGroupMentionButton } from "./ProjectGroupPage.js"; import { + InitializeProjectGroupButton, + ProjectGroupMentionButton, + projectGroupMessagesMatch, + resolveProjectGroupMessageRefresh, +} from "./ProjectGroupPage.js"; +import { + filterProjectGroupMentionTargets, + findProjectGroupMentionCandidates, MAX_PROJECT_GROUP_MESSAGE_LENGTH, formatProjectGroupMention, prependProjectGroupMention, prependProjectGroupMentionWithinLimit, + replaceProjectGroupMentionQuery, } from "./project-group-mention.js"; describe("formatProjectGroupMention", () => { @@ -89,6 +97,95 @@ describe("prependProjectGroupMention", () => { }); }); +describe("typed Project Group mentions", () => { + const agents = [ + { id: "agent-frontend", name: "Frontend Agent", role: "Frontend" as const, status: "ready" as const, currentContractId: null }, + { id: "agent-backend", name: "Backend Agent", role: "Backend" as const, status: "ready" as const, currentContractId: null }, + { id: "agent-general", name: "Generalist", role: "Generalist" as const, status: "ready" as const, currentContractId: null }, + ]; + + it("shows every callable Agent after a leading at-sign and filters a partial name prefix", () => { + expect(findProjectGroupMentionCandidates("@", 1, agents)).toEqual([agents[0], agents[1]]); + expect(findProjectGroupMentionCandidates("@f", 2, agents)).toEqual([agents[0]]); + }); + + it("filters typed mentions by visible Agent name rather than an opaque matching ID", () => { + const opaqueIdMatch = { id: "f-random-uuid", name: "Group Frontend", role: "Frontend" as const, status: "ready" as const, currentContractId: null }; + expect(findProjectGroupMentionCandidates("@F", 2, [...agents, opaqueIdMatch])).toEqual([agents[0]]); + }); + + it("keeps only ready uncontracted Frontend and Backend Agents callable", () => { + const unsetContract = { ...agents[0], id: "agent-unset-contract", name: "Unset Contract", currentContractId: undefined }; + expect(filterProjectGroupMentionTargets([ + agents[0], + agents[1], + unsetContract, + agents[2], + { ...agents[0], id: "agent-verification", name: "Verification", role: "Verification" as const }, + { ...agents[0], id: "agent-stopped", name: "Stopped", status: "stopped" as const }, + { ...agents[0], id: "agent-busy", name: "Busy", status: "busy" as const }, + { ...agents[0], id: "agent-error", name: "Error", status: "error" as const }, + { ...agents[0], id: "agent-contracted", name: "Contracted", currentContractId: "contract-1" }, + ])).toEqual([agents[0], agents[1], unsetContract]); + }); + + it("replaces the active leading partial mention with parser-safe syntax and retains the draft", () => { + expect(replaceProjectGroupMentionQuery("@F finish the form", 2, agents[0])).toEqual({ + content: '@"Frontend Agent" finish the form', + selectionStart: '@"Frontend Agent" '.length, + }); + }); + + it("accepts an exact-length expanded mention and rejects one character over the composer limit", () => { + const formattedMention = '@"Frontend Agent" '; + const exactDraft = "x".repeat(MAX_PROJECT_GROUP_MESSAGE_LENGTH - formattedMention.length); + expect(replaceProjectGroupMentionQuery(`@F ${exactDraft}`, 2, agents[0])).toEqual({ + content: `${formattedMention}${exactDraft}`, + selectionStart: formattedMention.length, + }); + expect(replaceProjectGroupMentionQuery(`@F ${exactDraft}x`, 2, agents[0])).toBeNull(); + }); + + it("does not suggest an Agent mention outside the leading routing directive", () => { + expect(findProjectGroupMentionCandidates("Ask @F for help", 6, agents)).toEqual([]); + }); +}); + +describe("Project Group message refresh", () => { + const message = { + id: "group-message", + projectId: "auth-demo", + missionId: null, + senderType: "human" as const, + senderId: null, + content: "use a bearer JWT", + targetAgentId: "agent-backend", + contractId: null, + createdAt: "2026-09-01T00:00:00.000Z", + }; + + it("treats a same-ID message with a completed Contract binding as a changed snapshot", () => { + expect(projectGroupMessagesMatch([message], [{ ...message, contractId: "contract-backend" }])).toBe(false); + }); + + it("consumes a send-follow request even when the fetched snapshot is unchanged", () => { + const currentMessages = [message]; + const afterSend = resolveProjectGroupMessageRefresh(currentMessages, [message], { + pinnedToMessageEnd: false, + followAfterSend: true, + }); + expect(afterSend.messages).toBe(currentMessages); + expect(afterSend.shouldFollowMessageEnd).toBe(true); + + const laterAppend = resolveProjectGroupMessageRefresh( + [message], + [...[message], { ...message, id: "other-message" }], + { pinnedToMessageEnd: false, followAfterSend: false }, + ); + expect(laterAppend.shouldFollowMessageEnd).toBe(false); + }); +}); + describe("ProjectGroupMentionButton", () => { it("is natively disabled while a message submission is in flight", () => { const markup = renderToStaticMarkup( diff --git a/apps/web/src/pages/project-group-mention.ts b/apps/web/src/pages/project-group-mention.ts index d127227..327a299 100644 --- a/apps/web/src/pages/project-group-mention.ts +++ b/apps/web/src/pages/project-group-mention.ts @@ -1,9 +1,24 @@ +import type { Agent } from "../types"; + const SIMPLE_AGENT_NAME = /^[\p{L}\p{N}_.-]+$/u; const MESSAGE_SPACING = /[\t\p{Zs}]+/gu; const FORBIDDEN_CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/u; const MAX_AGENT_KEY_LENGTH = 128; export const MAX_PROJECT_GROUP_MESSAGE_LENGTH = 2_000; +export type ProjectGroupMentionTarget = Pick; + +interface MentionQuery { + end: number; + query: string; +} + +function leadingMentionQuery(content: string, selectionStart: number): MentionQuery | null { + const match = /^@([\p{L}\p{N}_.-]*)/u.exec(content); + if (!match || selectionStart !== match[0].length) return null; + return { end: match[0].length, query: match[1]!.normalize("NFKC").toLocaleLowerCase("en-US") }; +} + function formatParserSafeAgentName(agentName: string): string | null { const normalizedName = agentName.normalize("NFKC").trim(); if ( @@ -40,3 +55,45 @@ export function prependProjectGroupMentionWithinLimit( const content = prependProjectGroupMention(agentName, agentId, composerContent); return content.length <= MAX_PROJECT_GROUP_MESSAGE_LENGTH ? content : null; } + +export function isProjectGroupMentionTarget(agent: ProjectGroupMentionTarget): boolean { + return (agent.role === "Frontend" || agent.role === "Backend") + && agent.status === "ready" + && (agent.currentContractId === null || agent.currentContractId === undefined); +} + +export function filterProjectGroupMentionTargets( + agents: readonly ProjectGroupMentionTarget[], +): ProjectGroupMentionTarget[] { + return agents.filter(isProjectGroupMentionTarget); +} + +export function findProjectGroupMentionCandidates( + content: string, + selectionStart: number, + agents: readonly ProjectGroupMentionTarget[], +): ProjectGroupMentionTarget[] { + const mention = leadingMentionQuery(content, selectionStart); + if (!mention) return []; + const callableAgents = filterProjectGroupMentionTargets(agents); + if (!mention.query) return callableAgents; + return callableAgents.filter((agent) => + agent.name.normalize("NFKC").toLocaleLowerCase("en-US").startsWith(mention.query), + ); +} + +export function replaceProjectGroupMentionQuery( + content: string, + selectionStart: number, + agent: ProjectGroupMentionTarget, +): { content: string; selectionStart: number } | null { + const mention = leadingMentionQuery(content, selectionStart); + if (!mention) return null; + const replacement = `${formatProjectGroupMention(agent.name, agent.id)} `; + const nextContent = `${replacement}${content.slice(mention.end).replace(/^\s/u, "")}`; + if (nextContent.length > MAX_PROJECT_GROUP_MESSAGE_LENGTH) return null; + return { + content: nextContent, + selectionStart: replacement.length, + }; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f92febd..1a8f58a 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2166,6 +2166,42 @@ label small { padding: 8px 12px 9px; border-width: 1px 0 0; border-radius: 0; + position: relative; +} + +.group-mention-suggestions { + position: absolute; + right: 12px; + bottom: calc(100% - 1px); + left: 12px; + max-height: 140px; + overflow-y: auto; + border: 1px solid #ded9f3; + border-radius: 8px 8px 0 0; + background: #f9f8f4; + box-shadow: 0 -5px 14px rgba(57, 49, 100, 0.1); +} + +.chat-composer > .group-mention-suggestions { + display: block; +} + +.group-mention-suggestions button { + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 0; + display: block; + color: #5e4ab7; + background: transparent; + font-size: 9px; + text-align: left; + cursor: pointer; +} + +.group-mention-suggestions button[aria-selected="true"], +.group-mention-suggestions button:hover { + background: #f0edfa; } /* Agents overview */ diff --git a/tests/e2e/project-group-contract-flow.spec.mjs b/tests/e2e/project-group-contract-flow.spec.mjs index 377cca8..d298cf0 100644 --- a/tests/e2e/project-group-contract-flow.spec.mjs +++ b/tests/e2e/project-group-contract-flow.spec.mjs @@ -61,6 +61,17 @@ async function assertNoDocumentOverflow(page, expectedViewport) { } } +async function assertNoPanelOverflow(page) { + const geometry = await page.locator(".group-chat-panel").evaluate((element) => ({ + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight, + })); + expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth); + expect(geometry.scrollHeight).toBeLessThanOrEqual(geometry.clientHeight); +} + async function captureEvidence(page, expectedViewport, name) { const screenshotDirectory = path.join( repositoryRoot, @@ -93,6 +104,11 @@ test("Project Group initializes safely, pairs bounded requests, and reports veri await unlock(page); await createAgent(page, "Group Frontend", "Frontend"); await createAgent(page, "Group Backend", "Backend"); + await createAgent(page, "Frontend Agent", "Frontend"); + for (let index = 1; index <= 5; index += 1) { + await createAgent(page, `Eligible Agent ${index}`, "Frontend"); + } + await createAgent(page, "General Agent", "Generalist"); await page.getByRole("link", { name: /Project Group/u }).click(); await expect(page.getByRole("button", { name: "Initialize Project Group", exact: true })).toBeVisible(); @@ -105,6 +121,57 @@ test("Project Group initializes safely, pairs bounded requests, and reports veri expect((await shepherdState(request)).missions).toHaveLength(0); const composer = page.getByLabel("Message Project Group"); + await composer.fill("@"); + const mentionSuggestions = page.getByRole("listbox", { name: "Agent mentions" }); + await expect(mentionSuggestions).toBeVisible(); + await expect(composer).toHaveAttribute("aria-autocomplete", "list"); + await expect(mentionSuggestions.getByRole("option")).toHaveCount(8); + await expect(page.getByLabel("Available mention targets").getByRole("button", { name: "@General Agent" })).toHaveCount(0); + const listGeometry = await mentionSuggestions.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + display: getComputedStyle(element).display, + optionRects: [...element.querySelectorAll('[role="option"]')].slice(0, 2).map((option) => option.getBoundingClientRect().toJSON()), + })); + expect(listGeometry.display).toBe("block"); + expect(listGeometry.scrollHeight).toBeGreaterThan(listGeometry.clientHeight); + expect(listGeometry.optionRects[1].top).toBeGreaterThanOrEqual(listGeometry.optionRects[0].bottom); + await assertNoPanelOverflow(page); + await assertNoDocumentOverflow(page, expectedViewport); + for (let index = 0; index < 6; index += 1) await composer.press("ArrowDown"); + const activeOption = mentionSuggestions.getByRole("option", { selected: true }); + const activeGeometry = await activeOption.evaluate((option) => { + const list = option.parentElement; + const optionRect = option.getBoundingClientRect(); + const listRect = list?.getBoundingClientRect(); + return { optionTop: optionRect.top, optionBottom: optionRect.bottom, listTop: listRect?.top, listBottom: listRect?.bottom }; + }); + expect(activeGeometry.optionTop).toBeGreaterThanOrEqual(activeGeometry.listTop); + expect(activeGeometry.optionBottom).toBeLessThanOrEqual(activeGeometry.listBottom); + await composer.press("Escape"); + await expect(mentionSuggestions).toHaveCount(0); + await expect(composer).toHaveValue("@"); + await composer.fill("@x"); + await composer.fill("@"); + const mentionOptions = mentionSuggestions.getByRole("option"); + const firstMentionName = (await mentionOptions.nth(0).textContent()).slice(1); + await composer.press("ArrowDown"); + await expect(mentionOptions.nth(1)).toHaveAttribute("aria-selected", "true"); + await composer.press("ArrowUp"); + await expect(mentionOptions.nth(0)).toHaveAttribute("aria-selected", "true"); + await composer.press("Tab"); + await expect(composer).toHaveValue(`@"${firstMentionName}" `); + await composer.fill("@F"); + await expect(mentionSuggestions.getByRole("option", { name: "Frontend Agent" })).toBeVisible(); + await expect(mentionSuggestions.getByRole("option")).toHaveCount(1); + await mentionSuggestions.getByRole("option", { name: "Frontend Agent" }).click(); + await expect(composer).toHaveValue('@"Frontend Agent" '); + const overflowDraft = "x".repeat(2_000); + await composer.fill(overflowDraft); + await page.getByLabel("Available mention targets").getByRole("button", { name: "@Frontend Agent" }).click(); + await expect(composer).toHaveValue(overflowDraft); + await expect(page.getByText("Mention would exceed the 2,000-character message limit.", { exact: true })).toBeVisible(); + await composer.fill(""); await composer.fill("Preserve this draft"); await page.getByLabel("Available mention targets").getByRole("button", { name: /Group Frontend/u }).click(); await expect(composer).toHaveValue('@"Group Frontend" Preserve this draft'); diff --git a/tests/e2e/ui-gate.spec.mjs b/tests/e2e/ui-gate.spec.mjs index befbffc..ac099a3 100644 --- a/tests/e2e/ui-gate.spec.mjs +++ b/tests/e2e/ui-gate.spec.mjs @@ -586,12 +586,20 @@ test("covers Project Group initialization, recovery, pending sends, and persiste await assertSafeUiGateSurface(page.locator(".group-chat-panel")); await assertNoSensitiveCanaries({ page }); + const manualScroll = await messagesPane.evaluate((element) => { + element.scrollTop = 0; + return { scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }; + }); + expect(manualScroll.scrollHeight).toBeGreaterThan(manualScroll.clientHeight + 1); + expect(manualScroll.scrollTop).toBe(0); + const subsequentPoll = page.waitForResponse((response) => response.request().method() === "GET" && /\/group-messages\?/u.test(response.url()) && response.status() === 200, ); await subsequentPoll; + await expect.poll(() => messagesPane.evaluate((element) => element.scrollTop)).toBe(0); for (const [index, content] of LONG_GROUP_MESSAGES.entries()) { const messagesAfterPoll = await request.get( `${app.baseURL}/api/shepherd/projects/auth-demo/group-messages`,