Fix mention chip alignment (#1094)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-06-17 20:38:13 +01:00
committed by GitHub
parent a4fbebb397
commit 4f93d52e15
8 changed files with 109 additions and 37 deletions

View File

@@ -6,7 +6,7 @@ import {
resolveEffectiveTimestamp,
} from "./readStateManager.ts";
const threadKey = "thread:" + "a".repeat(64);
const threadKey = `thread:${"a".repeat(64)}`;
const channelKey = "channel-1";
const channelResolver = (ctx) =>
ctx.startsWith("thread:") ? channelKey : null;

View File

@@ -16,6 +16,8 @@ import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
MENTION_CHIP_PREFIX_CLASS,
MESSAGE_MARKDOWN_CLASS,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -91,7 +93,9 @@ function ProfileName({
: "rounded-xs transition-colors hover:text-foreground",
)}
>
{highlight && !isAgentMention ? "@" : null}
{highlight && !isAgentMention ? (
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
) : null}
{children}
</span>
);
@@ -343,7 +347,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
profiles={profiles}
targetPubkey={payload.target}
/>
<div className="min-w-0 flex-1">
<div className={cn(MESSAGE_MARKDOWN_CLASS, "min-w-0 flex-1")}>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<div className="truncate text-sm font-semibold leading-none tracking-tight text-foreground">
{description.title}

View File

@@ -395,7 +395,6 @@ export function useTimelineScrollManager({
[onTargetReached, unpinFromBottom],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
React.useEffect(() => {
if (!targetMessageId) {
handledTargetMessageIdRef.current = null;

View File

@@ -896,6 +896,12 @@
color: hsl(var(--primary));
}
.message-markdown .mention-chip-prefix {
display: inline-block;
line-height: 1;
transform: translateY(-0.12em);
}
.message-markdown .inline-code-chip,
.message-markdown :not(pre) > code {
font-size: var(--inline-code-font-size);

View File

@@ -46,6 +46,7 @@ import {
INLINE_CODE_CHIP_CLASS,
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
MENTION_CHIP_PREFIX_CLASS,
MESSAGE_MARKDOWN_CLASS,
} from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
@@ -1941,9 +1942,15 @@ function createMarkdownComponents(
const isAgentMention =
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const renderedMentionText = isAgentMention
? mentionText.replace(/^@/, "")
: children;
const mentionLabel = mentionText.replace(/^@/, "");
const renderedMentionText = isAgentMention ? (
mentionLabel
) : (
<>
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
{mentionLabel}
</>
);
const mentionNode = (
<span
data-mention=""

View File

@@ -2,6 +2,8 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";
export const MENTION_CHIP_PREFIX_CLASS = "mention-chip-prefix";
/** Wrapper on rendered message Markdown — scopes inline chip CSS. */
export const MESSAGE_MARKDOWN_CLASS = "message-markdown";

View File

@@ -699,6 +699,49 @@ test("system add and remove rows use agent mention styling for managed agents",
).toHaveText("portal");
});
test("system member-joined rows render the joined person as a mention chip", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general", SYSTEM_MESSAGE_KIND);
await page.evaluate(
({ kind, pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: JSON.stringify({
type: "member_joined",
actor: pubkey,
target: pubkey,
}),
kind,
});
},
{ kind: SYSTEM_MESSAGE_KIND, pubkey: TEST_IDENTITIES.bob.pubkey },
);
await waitForTimelineSettled(page);
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "bob" })
.filter({ hasText: "joined the channel" });
const joinedPersonChip = joinedRow.locator("[data-mention].mention-chip", {
hasText: "bob",
});
await expect(joinedPersonChip).toBeVisible();
await expect(joinedPersonChip).toHaveCSS("display", /^(inline-)?flex$/);
await expect(joinedPersonChip).not.toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
await expect(joinedPersonChip.locator(".mention-chip-prefix")).toHaveText(
"@",
);
});
test("selecting a non-member agent from a DM inserts @Name into input", async ({
page,
}) => {
@@ -988,7 +1031,7 @@ test("Escape dismisses autocomplete dropdown", async ({ page }) => {
});
test("mention text is highlighted in sent messages", async ({ page }) => {
const suffix = `check this out ${Date.now()}`;
const suffix = ` check this out ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -1006,8 +1049,9 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
const mentionChip = page
.getByTestId("message-row")
.last()
.locator("[data-mention]", { hasText: "@bob" });
.locator("[data-mention].mention-chip", { hasText: "bob" });
await expect(mentionChip).toBeVisible();
await expect(mentionChip.locator(".mention-chip-prefix")).toHaveText("@");
await expect(mentionChip.locator("svg")).toHaveCount(0);
});

View File

@@ -4,6 +4,12 @@ import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/thread-unread";
type MockMessageEvent = {
id: string;
created_at: number;
pubkey: string;
};
async function waitForMockLiveSubscription(
page: import("@playwright/test").Page,
channelName: string,
@@ -26,7 +32,7 @@ async function waitForMockLiveSubscription(
.toBe(true);
}
function emitMockMessage(
async function emitMockMessage(
page: import("@playwright/test").Page,
channelName: string,
content: string,
@@ -36,8 +42,8 @@ function emitMockMessage(
createdAt?: number;
mentionPubkeys?: string[];
},
) {
return page.evaluate(
): Promise<MockMessageEvent> {
const event = await page.evaluate(
({ ch, msg, parentEventId, pubkey, ts, mentionPubkeys }) => {
return (
window as Window & {
@@ -68,6 +74,10 @@ function emitMockMessage(
mentionPubkeys: options?.mentionPubkeys,
},
);
if (!event) {
throw new Error("Mock message emitter is not installed");
}
return event;
}
// Unread thread replies must be dated strictly after the read frontier captured
@@ -231,7 +241,7 @@ test.describe("thread unread indicator screenshots", () => {
const base = unreadTimestamp();
for (let i = 0; i < 2; i++) {
await emitMockMessage(page, "general", `Bob chimes in ${i + 1}`, {
parentEventId: rootEvent!.id,
parentEventId: rootEvent.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: base + i,
});
@@ -278,19 +288,19 @@ test.describe("thread unread indicator screenshots", () => {
"general",
"Replying one level down",
{
parentEventId: r1!.id,
parentEventId: r1.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: past + 1,
},
);
// A sibling at r1's level so the tree reads as a branching discussion.
await emitMockMessage(page, "general", "Separate angle on the same point", {
parentEventId: r1!.id,
parentEventId: r1.id,
pubkey: TEST_IDENTITIES.charlie.pubkey,
createdAt: past + 2,
});
const r3 = await emitMockMessage(page, "general", "Going deeper still", {
parentEventId: r2!.id,
parentEventId: r2.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: past + 3,
});
@@ -302,8 +312,8 @@ test.describe("thread unread indicator screenshots", () => {
await expect(summary).toBeVisible();
await summary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expandReply(page, r1!.id);
await expandReply(page, r2!.id);
await expandReply(page, r1.id);
await expandReply(page, r2.id);
await page.getByTestId("message-thread-close").click();
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
@@ -314,12 +324,12 @@ test.describe("thread unread indicator screenshots", () => {
const base = unreadTimestamp();
const r4 = await emitMockMessage(page, "general", "New nested follow-up", {
parentEventId: r3!.id,
parentEventId: r3.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: base,
});
await emitMockMessage(page, "general", "Deepest unread reply", {
parentEventId: r4!.id,
parentEventId: r4.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base + 1,
});
@@ -331,10 +341,10 @@ test.describe("thread unread indicator screenshots", () => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("message-thread-summary").first().click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expandReply(page, r1!.id);
await expandReply(page, r2!.id);
await expandReply(page, r3!.id);
await expandReply(page, r4!.id);
await expandReply(page, r1.id);
await expandReply(page, r2.id);
await expandReply(page, r3.id);
await expandReply(page, r4.id);
// Fully expanded: r1, r2, sibling, r3, r4, r5 — six rendered replies.
const replies = page
@@ -371,7 +381,7 @@ test.describe("thread unread indicator screenshots", () => {
createdAt: past,
});
const c = await emitMockMessage(page, "general", "Child of branch parent", {
parentEventId: p!.id,
parentEventId: p.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: past + 1,
});
@@ -402,13 +412,13 @@ test.describe("thread unread indicator screenshots", () => {
"general",
"Unread under the branch",
{
parentEventId: c!.id,
parentEventId: c.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base,
},
);
await emitMockMessage(page, "general", "Another unread under the branch", {
parentEventId: c2!.id,
parentEventId: c2.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: base + 1,
});
@@ -444,7 +454,7 @@ test.describe("thread unread indicator screenshots", () => {
// Expanding p marks its whole subtree read; the descendant-inclusive gate
// (Phase 2.5) drops the badge from p and every revealed row beneath it.
await expandReply(page, p!.id);
await expandReply(page, p.id);
await expect(inPanelBadge).toHaveCount(0);
await page.screenshot({
@@ -469,7 +479,7 @@ test.describe("thread unread indicator screenshots", () => {
createdAt: past,
});
const c = await emitMockMessage(page, "general", "Child of branch parent", {
parentEventId: p!.id,
parentEventId: p.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: past + 1,
});
@@ -486,7 +496,7 @@ test.describe("thread unread indicator screenshots", () => {
const base = unreadTimestamp();
await emitMockMessage(page, "general", "First unread under branch", {
parentEventId: c!.id,
parentEventId: c.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base,
});
@@ -508,7 +518,7 @@ test.describe("thread unread indicator screenshots", () => {
// the badge must bump to 2 on the same tick — readStateVersion-driven
// recompute is what makes this fire live rather than on a later re-render.
await emitMockMessage(page, "general", "Second unread under branch", {
parentEventId: c!.id,
parentEventId: c.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: base + 1,
});
@@ -538,7 +548,7 @@ test.describe("thread unread indicator screenshots", () => {
createdAt: past,
});
const oldChild = await emitMockMessage(page, "general", "Old child", {
parentEventId: branchOld!.id,
parentEventId: branchOld.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: past + 1,
});
@@ -548,7 +558,7 @@ test.describe("thread unread indicator screenshots", () => {
createdAt: past + 2,
});
const newChild = await emitMockMessage(page, "general", "New child", {
parentEventId: branchNew!.id,
parentEventId: branchNew.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: past + 3,
});
@@ -570,12 +580,12 @@ test.describe("thread unread indicator screenshots", () => {
// sibling's badge survives until that branch is expanded too.
const base = unreadTimestamp();
await emitMockMessage(page, "general", "Unread in older branch", {
parentEventId: oldChild!.id,
parentEventId: oldChild.id,
pubkey: TEST_IDENTITIES.alice.pubkey,
createdAt: base,
});
await emitMockMessage(page, "general", "Unread in newer branch", {
parentEventId: newChild!.id,
parentEventId: newChild.id,
pubkey: TEST_IDENTITIES.bob.pubkey,
createdAt: base + 30,
});
@@ -599,7 +609,7 @@ test.describe("thread unread indicator screenshots", () => {
// `expandedSubtreeReplyIds` gate against the frozen open-time frontier.
// The older sibling's badge SURVIVES — the design does not sweep across
// branches off a live marker.
await expandReply(page, branchNew!.id);
await expandReply(page, branchNew.id);
await expect(inPanelBadges).toHaveCount(1);
await page.screenshot({
@@ -607,7 +617,7 @@ test.describe("thread unread indicator screenshots", () => {
});
// Expanding the older branch clears the last remaining badge.
await expandReply(page, branchOld!.id);
await expandReply(page, branchOld.id);
await expect(inPanelBadges).toHaveCount(0);
await page.screenshot({