Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
22 changes: 22 additions & 0 deletions desktop/src/features/messages/lib/mentionClipboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -440,3 +440,25 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => {
// whole must not gain a second accepted form.
assert.equal(matchChipTextToLabel("John…", "John Smith", "@"), "fragment");
});

test("whole compact mention labels are restored only for their declared exact key", () => {
const key = `150b20bd${"a".repeat(52)}15dc`;
const label = `Scout (${key}) 2`;
const compact = "Scout (150b20bd…15dc) 2";
assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated");
assert.equal(
matchChipTextToLabel(`@${compact}`, label, "@", key),
"truncated",
);
assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment");
assert.equal(
matchChipTextToLabel(compact, label, "@", "b".repeat(64)),
"fragment",
);
assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment");
assert.equal(
matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key),
"fragment",
);
assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment");
});
9 changes: 9 additions & 0 deletions desktop/src/features/messages/lib/mentionClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay";
import { truncateInlineChipLabel } from "@/shared/ui/mentionChip";

import { getMentionOffsets } from "./hasMention";
Expand Down Expand Up @@ -77,6 +78,7 @@ export function matchChipTextToLabel(
text: string,
label: string,
sigil: "@" | "#",
pubkey?: string,
): ChipTextMatch {
const body = canonicalMentionLabel(text);
const matches = (form: string) => body === form || body === `${sigil}${form}`;
Expand All @@ -87,6 +89,13 @@ export function matchChipTextToLabel(
if (truncated !== label && matches(canonicalMentionLabel(truncated))) {
return "truncated";
}
// Read-only mentions abbreviate a bound key, never the identity carried by
// the clipboard. Restore the full literal label only for the whole display.
const compact =
sigil === "@" ? formatMentionDisplayLabel(label, pubkey) : label;
if (compact !== label && matches(canonicalMentionLabel(compact))) {
return "truncated";
}
return "fragment";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,19 @@ test("an ordinary chip still flattens to a registrable mention", () => {
[JOHN_SMITH_PUBKEY],
);
});

test("compact mention paste expands only complete bound labels", () => {
const key = `150b20bd${"a".repeat(52)}15dc`;
const label = `Scout (${key}) 2`;
const html = (text) =>
`<span data-mention="" data-mention-pubkey="${key}" ` +
`data-mention-label="${label}">${text}</span>`;
assert.equal(
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text,
`@${label}`,
);
assert.equal(
normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text,
"Scout (150b20bd…15dc)",
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CHANNEL_LABEL_ATTRIBUTE,
matchChipTextToLabel,
MENTION_LABEL_ATTRIBUTE,
MENTION_PUBKEY_ATTRIBUTE,
} from "./mentionClipboard";

/**
Expand Down Expand Up @@ -173,7 +174,14 @@ export function normalizeMentionClipboardContent(
// from the same attribute the records carry keeps the two provably
// consistent, whatever the classifier goes on to tolerate.
const match =
label === null ? "full" : matchChipTextToLabel(text, label, sigil);
label === null
? "full"
: matchChipTextToLabel(
text,
label,
sigil,
el.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined,
);
span.textContent =
match === "fragment"
? text
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/messages/lib/timelineMentionCopy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,18 @@ test("copy inlines a blockified chip but preserves its block ancestor", () => {
else delete prototype.innerText;
}
});

test("copy expands compact key text but preserves the exact label and identity", () => {
const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`;
const flavors = copyRenderedBody(
`<span data-mention="" data-mention-pubkey="${JOHN_SMITH_PUBKEY}" ` +
`data-mention-label="${label}" class="mention-chip">` +
'<span class="inline-chip-leading-fragment">Scout</span> (7c7c7c7c…7c7c) 2</span>',
);
assert.ok(flavors);
assert.ok(flavors.html.includes(`@${label}`));
assert.ok(
flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`),
);
assert.ok(!flavors.html.includes("…"));
});
7 changes: 6 additions & 1 deletion desktop/src/features/messages/lib/timelineMentionCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ function restoreChipSigils(root: HTMLElement): boolean {
const label = element.getAttribute(MENTION_LABEL_ATTRIBUTE);
if (
label &&
matchChipTextToLabel(element.textContent ?? "", label, "@") !== "fragment"
matchChipTextToLabel(
element.textContent ?? "",
label,
"@",
element.getAttribute(MENTION_PUBKEY_ATTRIBUTE) ?? undefined,
) !== "fragment"
) {
element.textContent = `@${label}`;
restored = true;
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/shared/lib/mentionDisplay.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatMentionDisplayLabel } from "./mentionDisplay.ts";
import { truncatePubkey } from "./pubkey.ts";

const KEY = `150b20bd${"a".repeat(52)}15dc`;

test("compact mention display uses the member-list formatter and keeps collision suffixes", () => {
for (const suffix of ["", " 2", " 10"]) {
assert.equal(
formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY),
`Bad Janet (${truncatePubkey(KEY)})${suffix}`,
);
}
assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY));
assert.equal(
formatMentionDisplayLabel(KEY.toUpperCase(), KEY),
truncatePubkey(KEY.toUpperCase()),
);
});

test("display leaves unbound, mismatched, malformed and ordinary labels literal", () => {
for (const [label, key] of [
[`Bad Janet (${KEY})`, undefined],
[`Bad Janet (${KEY})`, "b".repeat(64)],
[`Bad Janet (${KEY})`, "bad-key"],
[`Bad Janet (${KEY}) 1`, KEY],
[`Bad Janet (${KEY}) notes`, KEY],
[`Release ${KEY}`, KEY],
["Bad Janet", KEY],
])
assert.equal(formatMentionDisplayLabel(label, key), label);
});

test("matching compact keys do not become identity keys", () => {
const other = KEY.replace("aaaa", "bbbb");
assert.notEqual(KEY, other);
assert.equal(
formatMentionDisplayLabel(`Scout (${KEY})`, KEY),
formatMentionDisplayLabel(`Scout (${other})`, other),
);
assert.equal(
formatMentionDisplayLabel(`Scout (${KEY})`, other),
`Scout (${KEY})`,
);
});
17 changes: 17 additions & 0 deletions desktop/src/shared/lib/mentionDisplay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { truncatePubkey } from "./pubkey";

/** Compact only a bound mention's key; its literal label remains authoritative. */
export function formatMentionDisplayLabel(
label: string,
pubkey: string | undefined,
): string {
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label;
if (label.toLowerCase() === pubkey.toLowerCase()) {
return truncatePubkey(label);
}
const qualified = label.match(
/^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i,
);
if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label;
return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`;
}
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
23 changes: 20 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,12 @@ 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 { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay";
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 +33,9 @@ export function createMarkdownMention(interactive: boolean) {
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
const displayLabel = formatMentionDisplayLabel(mentionLabel, pubkey);
const icon = isAgentMention ? "agent" : "human";
const leadingEnd = inlineChipLeadingEnd(displayLabel);
// 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 +54,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),
)}
>
{displayLabel.slice(0, leadingEnd)}
</span>
{displayLabel.slice(leadingEnd)}
{isAgentMention ? <AgentManagementMarker pubkey={pubkey} /> : null}
</InlineChip>
);
Expand Down
69 changes: 69 additions & 0 deletions desktop/src/shared/ui/markdownMentionDisplay.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { truncatePubkey } from "../lib/pubkey.ts";
import { createMarkdownComponents } from "./markdown.tsx";
import { renderCachedMarkdown } from "./markdown/nodeCache.ts";
import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts";

const KEY = `150b20bd${"a".repeat(52)}15dc`;

for (const agent of [false, true]) {
test(`rendered ${agent ? "agent" : "human"} abbreviates a bound key without changing its metadata`, () => {
const label = `Scout (${KEY}) 2`;
const name = label.toLowerCase();
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [],
mentionPubkeysByName: { [name]: KEY },
agentMentionPubkeysByName: agent ? { [name]: KEY } : {},
},
},
renderCachedMarkdown({
content: `Ask @${label}`,
mentionNames: [label],
components: createMarkdownComponents(false, false),
variant: `compact-mention-${agent}`,
}),
),
);
assert.equal(
html.replace(/<[^>]+>/g, ""),
`Ask Scout (${truncatePubkey(KEY)}) 2`,
);
assert.ok(html.includes(`data-mention-label="${label}"`));
assert.ok(html.includes(`data-mention-pubkey="${KEY}"`));
assert.ok(html.includes(`title="${label}"`));
assert.ok(html.includes(`aria-label="${label}"`));
assert.match(
html,
new RegExp(
`inline-chip-leading-fragment[^>]*inline-chip-icon-${agent ? "agent" : "human"}`,
),
);
});
}

test("an unresolved qualified mention stays literal rather than claiming an abbreviated identity", () => {
const label = `Scout (${KEY})`;
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: { channels: [], mentionPubkeysByName: {} },
},
renderCachedMarkdown({
content: `Ask @${label}`,
mentionNames: [label],
components: createMarkdownComponents(false, false),
variant: "unresolved-compact-mention",
}),
),
);
assert.ok(html.includes(`@${label}`));
assert.doesNotMatch(html, /data-mention=/);
});
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
Loading
Loading