Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions desktop/src/shared/ui/markdown.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1400,7 +1400,12 @@ test("resolved human mentions replace the authored at-sign with the shared icon"
);

assert.match(html, /data-mention=""/);
assert.match(html, /inline-chip-icon-human/);
assert.match(
html,
/inline-chip-leading-fragment[^>]*inline-chip-icon-human[^>]*>alice<\/span>/,
);
assert.match(html, /aria-label="alice"/);
assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alice</);
assert.match(html, />alice</);
assert.doesNotMatch(html, />@alice</);
});
Expand Down Expand Up @@ -1432,7 +1437,12 @@ test("agent mentions retain the bot treatment instead of the human icon", () =>

assert.match(html, /data-mention=""/);
assert.match(html, /agent-mention-highlight/);
assert.match(html, /inline-chip-icon-agent/);
assert.match(
html,
/inline-chip-leading-fragment[^>]*inline-chip-icon-agent[^>]*>alice<\/span>/,
);
assert.match(html, /aria-label="alice"/);
assert.doesNotMatch(html, /aria-hidden="true"[^>]*>alice</);
assert.match(html, />alice</);
assert.doesNotMatch(html, />@alice</);
});
Expand Down
21 changes: 18 additions & 3 deletions desktop/src/shared/ui/markdown/MarkdownMention.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import type * as React from "react";
import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { WRAPPING_INLINE_CHIP_CLASSES } from "@/shared/ui/mentionChip";
import {
inlineChipIconClasses,
inlineChipLeadingEnd,
WRAPPING_INLINE_CHIP_CLASSES,
} from "@/shared/ui/mentionChip";
import { InlineChip } from "@/shared/ui/InlineChip";
import { useMarkdownRuntime } from "./runtimeContext";

Expand All @@ -28,6 +32,8 @@ export function createMarkdownMention(interactive: boolean) {
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
const icon = isAgentMention ? "agent" : "human";
const leadingEnd = inlineChipLeadingEnd(mentionLabel);
// Only chips that actually open a profile get the clickable affordance.
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
// cursor there promises a click that does nothing.
Expand All @@ -46,10 +52,19 @@ export function createMarkdownMention(interactive: boolean) {
)}
title={mentionLabel}
aria-label={mentionLabel}
icon={isAgentMention ? "agent" : "human"}
icon={icon}
interactive={opensProfile}
>
{mentionLabel}
{/* Wrapping chips hide the outer icon; keep it with a bounded prefix. */}
<span
className={cn(
"inline-chip-leading-fragment",
inlineChipIconClasses(icon),
)}
>
{mentionLabel.slice(0, leadingEnd)}
</span>
{mentionLabel.slice(leadingEnd)}
{isAgentMention ? <AgentManagementMarker pubkey={pubkey} /> : null}
</InlineChip>
);
Expand Down
7 changes: 7 additions & 0 deletions desktop/tests/e2e/cloud-provenance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ for (const agentListDelayMs of [0, 6_000]) {
).toBeVisible();
const chip = article.locator("[data-mention]");
await expect(chip.locator("svg.lucide-cloud")).toBeVisible();
const leading = chip.locator(".inline-chip-leading-fragment");
await expect(leading).toHaveText("Remot");
expect(
await leading.evaluate(
(element) => getComputedStyle(element, "::before").display,
),
).toBe("block");
await waitForAnimations(page);
await article.screenshot({
path: testInfo.outputPath("cloud-author-chip.png"),
Expand Down
15 changes: 12 additions & 3 deletions desktop/tests/e2e/mention-clipboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,11 @@ async function copyFromTimeline(
selection.removeAllRanges();
const range = document.createRange();
if (selectPartialChip) {
const label = chip.firstChild;
if (!label) throw new Error("Mention chip has no text node.");
const walker = document.createTreeWalker(chip, NodeFilter.SHOW_TEXT);
const label = walker.nextNode();
if (!label?.nodeValue?.startsWith("John")) {
throw new Error("Mention chip has no leading John text node.");
}
range.setStart(label, 0);
range.setEnd(label, 4);
} else {
Expand Down Expand Up @@ -202,6 +205,11 @@ async function openInboxMentionItem(page: Page) {
},
);

const preview = page
.getByTestId(`home-inbox-item-${item.id}`)
.locator("[data-mention]");
await expect(preview).toHaveText("John Smith");
expect(await preview.ariaSnapshot()).toContain("John");
await page.getByTestId(`home-inbox-item-${item.id}`).click();
// The inbox resolves a non-member's display name off its own profile batch,
// so wait for the chip's identity rather than for the row — same setup
Expand All @@ -210,6 +218,7 @@ async function openInboxMentionItem(page: Page) {
.getByTestId("home-inbox-detail-scroll")
.locator(`[data-mention-pubkey="${JOHN_SMITH_PUBKEY}"]`);
await expect(chip).toHaveText("John Smith", { timeout: 15_000 });
expect(await chip.ariaSnapshot()).toContain("John");
return item;
}

Expand Down Expand Up @@ -930,7 +939,7 @@ test("a boundary-crossing default copy pastes its chip fragment without a sigil"
return nodes;
};
const label = textNodesUnder(chip).find((node) =>
node.nodeValue?.includes("John Smith"),
node.nodeValue?.includes("Smith"),
);
const tail = textNodesUnder(body).find((node) =>
node.nodeValue?.includes("fixed the bug"),
Expand Down
64 changes: 57 additions & 7 deletions desktop/tests/e2e/mention-recipients.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const SECOND = TEST_IDENTITIES.bob.pubkey;
const AMBIGUOUS =
"The mention @Scout is ambiguous. Choose a recipient from the mention picker.";

async function install(page: Page, channel = "general") {
async function install(page: Page, channel = "general", agents = false) {
await installMockBridge(page, {
managedAgents:
channel === "watercooler"
Expand All @@ -17,10 +17,18 @@ async function install(page: Page, channel = "general") {
status: "running",
channelNames: ["watercooler"],
}))
: [],
: agents
? [FIRST, SECOND].map((pubkey) => ({
pubkey,
name: "Scout",
status: "running",
channelNames: [channel],
}))
: [],
searchProfiles: [FIRST, SECOND].map((pubkey) => ({
pubkey,
displayName: "Scout",
isAgent: agents,
})),
});
await page.goto("/");
Expand Down Expand Up @@ -419,12 +427,19 @@ test("editing to a longer typed member drops the original shorter reference", as
await expect(row).toContainText("Scout Jones hello");
});

for (const scale of [1, 1.5]) {
test(`exact-key chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({
// The default relay directory knows FIRST as an agent; SECOND is a human.
// The agent variant makes SECOND managed too, covering a qualified bot label.
for (const { kind, scale } of [
{ kind: "mixed", scale: 1 },
{ kind: "mixed", scale: 1.5 },
{ kind: "agent", scale: 1 },
{ kind: "agent", scale: 1.5 },
]) {
test(`exact-key ${kind} chips wrap in narrow composer, sent message and reopen at ${scale}x text`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 800, height: 900 });
await install(page);
await install(page, "general", kind === "agent");
await page.evaluate((scale) => {
document.documentElement.style.fontSize = `${16 * scale}px`;
}, scale);
Expand Down Expand Up @@ -460,6 +475,13 @@ for (const scale of [1, 1.5]) {
host: import("@playwright/test").Locator,
stage: string,
) => {
if (stage === "sent") {
await expect(host.locator("[data-mention]")).toHaveCount(2);
await expect(host.locator("[data-mention]").last()).toHaveAttribute(
"data-mention-kind",
kind === "agent" ? "agent" : "human",
);
}
const result = await geometry(host);
expect(result.chips.length).toBeGreaterThanOrEqual(2);
expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1);
Expand All @@ -475,7 +497,35 @@ for (const scale of [1, 1.5]) {
expect(rect.right).toBeLessThanOrEqual(result.width + 1);
}
}
if (stage !== "sent") {
if (stage === "sent") {
for (const chip of await host.locator("[data-mention]").all()) {
const expectedKind =
kind === "agent" ||
(await chip.getAttribute("data-mention-pubkey")) === FIRST
? "agent"
: "human";
await expect(chip).toHaveAttribute("data-mention-kind", expectedKind);
const leading = chip.locator(".inline-chip-leading-fragment");
await expect(leading).toHaveText("Scout");
expect(await leading.ariaSnapshot()).toContain("Scout");
const icon = await leading.evaluate((element) => {
const style = getComputedStyle(element, "::before");
return {
display: style.display,
mask: style.maskImage,
width: parseFloat(style.width),
height: parseFloat(style.height),
};
});
expect(icon.display).toBe("block");
expect(icon.mask).toContain("data:image/svg+xml");
expect(icon.width).toBeGreaterThan(0);
expect(icon.height).toBeGreaterThan(0);
await expect(leading).toHaveClass(
new RegExp(`inline-chip-icon-${expectedKind}`),
);
}
} else {
for (const prefix of await host
.locator(".mention-prefix-hidden")
.all()) {
Expand All @@ -495,7 +545,7 @@ for (const scale of [1, 1.5]) {
});
await waitForAnimations(page);
await page.screenshot({
path: `test-results/mention-recipients/layout-${scale}-${stage}.png`,
path: `test-results/mention-recipients/layout-${kind}-${scale}-${stage}.png`,
});
};
await expect(input).toHaveText(content);
Expand Down
Loading