From 0ca2faa477f55b9cb8ef79ce8879a89f76987616 Mon Sep 17 00:00:00 2001 From: sanjey99 Date: Tue, 1 Sep 2026 11:25:43 +0800 Subject: [PATCH 1/4] fix: preserve project chat scroll position Add leading Agent mention suggestions that reuse the existing safe routing syntax and keep manually-scrolled group conversations stable across polling refreshes. --- apps/web/src/pages/ProjectGroupPage.tsx | 117 +++++++++++++++++- .../src/pages/project-group-mention.test.tsx | 26 ++++ apps/web/src/pages/project-group-mention.ts | 44 +++++++ apps/web/src/styles.css | 32 +++++ .../e2e/project-group-contract-flow.spec.mjs | 12 ++ tests/e2e/ui-gate.spec.mjs | 8 ++ 6 files changed, 235 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/ProjectGroupPage.tsx b/apps/web/src/pages/ProjectGroupPage.tsx index bd19f00..efa26fb 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,26 @@ import type { Agent, ProjectGroupMessage } from "../types"; import { EmptyState, ErrorState, Icon, LoadingPanel, PageHeader, Spinner, StatePill, formatTime, shortId } from "../ui"; import { MAX_PROJECT_GROUP_MESSAGE_LENGTH, + 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 messagesMatch( + current: readonly ProjectGroupMessage[], + next: readonly ProjectGroupMessage[], +): boolean { + return current.length === next.length && current.every((message, index) => message.id === next[index]?.id); +} + function senderName(message: ProjectGroupMessage, agents: Agent[]): string { if (message.senderType === "human") return "You"; if (message.senderType === "shepherd") return "Shepherd"; @@ -56,9 +73,21 @@ 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 messageComposer = useRef(null); const inFlight = useRef(false); + const scrollToMessageEndOnChange = useRef(true); + const scrollAfterSend = useRef(false); + + const mentionCandidates = useMemo( + () => findProjectGroupMentionCandidates(content, mentionCursor, agents), + [agents, content, mentionCursor], + ); + const showMentionSuggestions = Boolean(project) && !sending && !mentionDismissed && mentionCandidates.length > 0; const insertMention = (agent: Agent) => { if (sending) return; @@ -69,6 +98,8 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { } else { setComposerError(null); setContent(nextContent); + setMentionCursor(nextContent.length); + setMentionDismissed(false); } }); const composer = messageComposer.current; @@ -82,7 +113,15 @@ 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; + setMessages((current) => { + if (messagesMatch(current, result.messages)) return current; + scrollToMessageEndOnChange.current = scrollAfterSend.current || current.length === 0 || pinnedToMessageEnd; + scrollAfterSend.current = false; + return result.messages; + }); setMessageError(null); } catch (reason) { setMessageError(reason instanceof Error ? reason.message : "Project Group is unavailable"); @@ -103,10 +142,31 @@ 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]); + + const selectMention = (agent: ProjectGroupMentionTarget) => { + const next = replaceProjectGroupMentionQuery(content, mentionCursor, agent); + if (!next) 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 +199,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"); @@ -172,7 +235,7 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { {messageError ? (
{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)} 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 +316,27 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { disabled={!project || sending} maxLength={MAX_PROJECT_GROUP_MESSAGE_LENGTH} aria-describedby={composerError ? "group-message-error" : undefined} + 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..f662d47 100644 --- a/apps/web/src/pages/project-group-mention.test.tsx +++ b/apps/web/src/pages/project-group-mention.test.tsx @@ -4,10 +4,12 @@ import { describe, expect, it } from "vitest"; import { parseProjectGroupMessage } from "../../../server/src/shepherd/group-routing.js"; import { InitializeProjectGroupButton, ProjectGroupMentionButton } from "./ProjectGroupPage.js"; import { + findProjectGroupMentionCandidates, MAX_PROJECT_GROUP_MESSAGE_LENGTH, formatProjectGroupMention, prependProjectGroupMention, prependProjectGroupMentionWithinLimit, + replaceProjectGroupMentionQuery, } from "./project-group-mention.js"; describe("formatProjectGroupMention", () => { @@ -89,6 +91,30 @@ describe("prependProjectGroupMention", () => { }); }); +describe("typed Project Group mentions", () => { + const agents = [ + { id: "agent-frontend", name: "Frontend Agent" }, + { id: "agent-backend", name: "Backend Agent" }, + { id: "agent-general", name: "Generalist" }, + ]; + + it("shows every Agent after a leading at-sign and filters a partial name prefix", () => { + expect(findProjectGroupMentionCandidates("@", 1, agents)).toEqual(agents); + expect(findProjectGroupMentionCandidates("@f", 2, agents)).toEqual([agents[0]]); + }); + + 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("does not suggest an Agent mention outside the leading routing directive", () => { + expect(findProjectGroupMentionCandidates("Ask @F for help", 6, agents)).toEqual([]); + }); +}); + 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..db33bfd 100644 --- a/apps/web/src/pages/project-group-mention.ts +++ b/apps/web/src/pages/project-group-mention.ts @@ -4,6 +4,22 @@ const FORBIDDEN_CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u00 const MAX_AGENT_KEY_LENGTH = 128; export const MAX_PROJECT_GROUP_MESSAGE_LENGTH = 2_000; +export interface ProjectGroupMentionTarget { + id: string; + name: string; +} + +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 +56,31 @@ export function prependProjectGroupMentionWithinLimit( const content = prependProjectGroupMention(agentName, agentId, composerContent); return content.length <= MAX_PROJECT_GROUP_MESSAGE_LENGTH ? content : null; } + +export function findProjectGroupMentionCandidates( + content: string, + selectionStart: number, + agents: readonly ProjectGroupMentionTarget[], +): ProjectGroupMentionTarget[] { + const mention = leadingMentionQuery(content, selectionStart); + if (!mention) return []; + if (!mention.query) return [...agents]; + return agents.filter((agent) => + agent.name.normalize("NFKC").toLocaleLowerCase("en-US").startsWith(mention.query) + || agent.id.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)} `; + return { + content: `${replacement}${content.slice(mention.end).replace(/^\s/u, "")}`, + selectionStart: replacement.length, + }; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f92febd..f9a10f0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2166,6 +2166,38 @@ 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); +} + +.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..3e79a05 100644 --- a/tests/e2e/project-group-contract-flow.spec.mjs +++ b/tests/e2e/project-group-contract-flow.spec.mjs @@ -93,6 +93,7 @@ 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"); await page.getByRole("link", { name: /Project Group/u }).click(); await expect(page.getByRole("button", { name: "Initialize Project Group", exact: true })).toBeVisible(); @@ -105,6 +106,17 @@ 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(mentionSuggestions.getByRole("option")).toHaveCount(3); + await composer.fill("@F"); + await expect(mentionSuggestions.getByRole("option", { name: "Frontend Agent" })).toBeVisible(); + await expect(mentionSuggestions.getByRole("option")).toHaveCount(1); + await composer.press("ArrowDown"); + await composer.press("Enter"); + await expect(composer).toHaveValue('@"Frontend Agent" '); + 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`, From 69c4e86b16df6329d2d16c770b52e74d0afd6ef7 Mon Sep 17 00:00:00 2001 From: sanjey99 Date: Tue, 1 Sep 2026 11:30:41 +0800 Subject: [PATCH 2/4] fix: retain enriched group messages Compare complete Project Group message snapshots, consume send-follow intent after the next refresh, and cover keyboard and pointer mention selection. --- apps/web/src/pages/ProjectGroupPage.tsx | 51 ++++++++++++++++--- .../src/pages/project-group-mention.test.tsx | 42 ++++++++++++++- .../e2e/project-group-contract-flow.spec.mjs | 6 ++- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/ProjectGroupPage.tsx b/apps/web/src/pages/ProjectGroupPage.tsx index efa26fb..34b95ab 100644 --- a/apps/web/src/pages/ProjectGroupPage.tsx +++ b/apps/web/src/pages/ProjectGroupPage.tsx @@ -20,11 +20,42 @@ function isPinnedToMessageEnd(element: HTMLElement): boolean { return element.scrollHeight - element.scrollTop - element.clientHeight <= SCROLL_PINNED_TOLERANCE; } -function messagesMatch( +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) => message.id === next[index]?.id); + 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 { @@ -116,12 +147,20 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { const pinnedToMessageEnd = messagesPane.current ? isPinnedToMessageEnd(messagesPane.current) : true; + const followAfterSend = scrollAfterSend.current; + scrollAfterSend.current = false; setMessages((current) => { - if (messagesMatch(current, result.messages)) return current; - scrollToMessageEndOnChange.current = scrollAfterSend.current || current.length === 0 || pinnedToMessageEnd; - scrollAfterSend.current = false; - return result.messages; + 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"); diff --git a/apps/web/src/pages/project-group-mention.test.tsx b/apps/web/src/pages/project-group-mention.test.tsx index f662d47..0848006 100644 --- a/apps/web/src/pages/project-group-mention.test.tsx +++ b/apps/web/src/pages/project-group-mention.test.tsx @@ -2,7 +2,12 @@ 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 { findProjectGroupMentionCandidates, MAX_PROJECT_GROUP_MESSAGE_LENGTH, @@ -115,6 +120,41 @@ describe("typed Project Group mentions", () => { }); }); +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/tests/e2e/project-group-contract-flow.spec.mjs b/tests/e2e/project-group-contract-flow.spec.mjs index 3e79a05..ec12efa 100644 --- a/tests/e2e/project-group-contract-flow.spec.mjs +++ b/tests/e2e/project-group-contract-flow.spec.mjs @@ -110,11 +110,13 @@ test("Project Group initializes safely, pairs bounded requests, and reports veri const mentionSuggestions = page.getByRole("listbox", { name: "Agent mentions" }); await expect(mentionSuggestions).toBeVisible(); await expect(mentionSuggestions.getByRole("option")).toHaveCount(3); + await composer.press("ArrowDown"); + await composer.press("Enter"); + await expect(composer).toHaveValue('@"Group Backend" '); await composer.fill("@F"); await expect(mentionSuggestions.getByRole("option", { name: "Frontend Agent" })).toBeVisible(); await expect(mentionSuggestions.getByRole("option")).toHaveCount(1); - await composer.press("ArrowDown"); - await composer.press("Enter"); + await mentionSuggestions.getByRole("option", { name: "Frontend Agent" }).click(); await expect(composer).toHaveValue('@"Frontend Agent" '); await composer.fill(""); await composer.fill("Preserve this draft"); From 9b3082608b1a2a7980feddca65e2ed77ebbf4ec5 Mon Sep 17 00:00:00 2001 From: sanjey99 Date: Tue, 1 Sep 2026 11:35:19 +0800 Subject: [PATCH 3/4] fix: filter group mentions by name Keep autocomplete suggestions aligned with visible Agent names so opaque generated IDs cannot produce unexpected matches. --- apps/web/src/pages/project-group-mention.test.tsx | 5 +++++ apps/web/src/pages/project-group-mention.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/project-group-mention.test.tsx b/apps/web/src/pages/project-group-mention.test.tsx index 0848006..8c454f1 100644 --- a/apps/web/src/pages/project-group-mention.test.tsx +++ b/apps/web/src/pages/project-group-mention.test.tsx @@ -108,6 +108,11 @@ describe("typed Project Group mentions", () => { 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" }; + expect(findProjectGroupMentionCandidates("@F", 2, [...agents, opaqueIdMatch])).toEqual([agents[0]]); + }); + 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', diff --git a/apps/web/src/pages/project-group-mention.ts b/apps/web/src/pages/project-group-mention.ts index db33bfd..251216b 100644 --- a/apps/web/src/pages/project-group-mention.ts +++ b/apps/web/src/pages/project-group-mention.ts @@ -66,8 +66,7 @@ export function findProjectGroupMentionCandidates( if (!mention) return []; if (!mention.query) return [...agents]; return agents.filter((agent) => - agent.name.normalize("NFKC").toLocaleLowerCase("en-US").startsWith(mention.query) - || agent.id.normalize("NFKC").toLocaleLowerCase("en-US").startsWith(mention.query), + agent.name.normalize("NFKC").toLocaleLowerCase("en-US").startsWith(mention.query), ); } From 8e4ca87f4c9469c54fe920f14b0af44c15f1a61c Mon Sep 17 00:00:00 2001 From: sanjey99 Date: Tue, 1 Sep 2026 11:54:52 +0800 Subject: [PATCH 4/4] fix: bound callable group mentions Limit Project Group suggestions to idle auth-demo Agents, preserve drafts when a mention would exceed the composer limit, and keep overflowing suggestion options vertically scrollable and keyboard accessible. --- apps/web/src/pages/ProjectGroupPage.tsx | 27 +++++++-- .../src/pages/project-group-mention.test.tsx | 38 ++++++++++-- apps/web/src/pages/project-group-mention.ts | 28 ++++++--- apps/web/src/styles.css | 4 ++ .../e2e/project-group-contract-flow.spec.mjs | 59 ++++++++++++++++++- 5 files changed, 134 insertions(+), 22 deletions(-) diff --git a/apps/web/src/pages/ProjectGroupPage.tsx b/apps/web/src/pages/ProjectGroupPage.tsx index 34b95ab..6524099 100644 --- a/apps/web/src/pages/ProjectGroupPage.tsx +++ b/apps/web/src/pages/ProjectGroupPage.tsx @@ -7,6 +7,7 @@ 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, @@ -109,18 +110,20 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { 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, agents), - [agents, content, mentionCursor], + () => 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(() => { @@ -191,9 +194,19 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) { 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) return; + if (!next) { + setComposerError("Mention would exceed the 2,000-character message limit."); + return; + } flushSync(() => { setContent(next.content); setMentionCursor(next.selectionStart); @@ -262,7 +275,7 @@ export function ProjectGroupPage({ agents }: { agents: Agent[] }) {
Route directly: - {agents.map((agent) => ( + {callableAgents.map((agent) => ( setMentionCursor(event.currentTarget.selectionStart)} + onKeyUp={(event) => setMentionCursor(event.currentTarget.selectionStart)} onKeyDown={(event) => { if (showMentionSuggestions) { if (event.key === "ArrowDown") { @@ -355,12 +369,13 @@ 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) => (