improve(ui): compact GitHub links in chat (#125746)

* improve(ui): compact GitHub links in chat

* fix(ui): refine GitHub link pills
This commit is contained in:
Vyctor H. Brzezowski
2026-08-18 07:59:37 -03:00
committed by GitHub
parent 40597e25b5
commit 29b1a65d1e
6 changed files with 59 additions and 62 deletions
+21 -12
View File
@@ -22,7 +22,7 @@ function decodePathSegment(value: string): string | null {
}
}
export function parseGitHubItemPath(url: URL): GitHubItemTarget | null {
function parseGitHubItemPath(url: URL): GitHubItemTarget | null {
const segments = url.pathname.split("/").filter(Boolean);
const owner = decodePathSegment(segments[0] ?? "");
const repo = decodePathSegment(segments[1] ?? "");
@@ -52,18 +52,27 @@ export function parseGitHubLinkTarget(href: string): GitHubLinkTarget | null {
return target ? { ...target, href: url.href } : null;
}
export function formatGitHubItemReference(target: GitHubItemTarget): string {
return `${target.owner}/${target.repo}#${target.number}`;
}
// Compaction to `owner/repo#N` is only safe when the URL names the generic
// item root: any trailing path segment (/files, /commits, an issue comment
// anchor) or query/fragment is a more specific destination than the compact
// label communicates, even though `parseGitHubItemPath` still resolves an
// identity for it (hovercards and navigation need that deep identity intact).
export function isGitHubItemRootPath(url: URL): boolean {
export function formatGitHubLinkLabel(url: URL): string {
const segments = url.pathname.split("/").filter(Boolean);
return segments.length === 4 && !url.search && !url.hash;
const item = parseGitHubItemPath(url);
if (item && segments.length === 4 && !url.search && !url.hash) {
return `#${item.number}`;
}
if (item) {
return url.href;
}
if (segments.length === 2) {
return segments.map((segment) => decodePathSegment(segment) ?? segment).join("/");
}
if (segments[2] === "blob" && segments.length > 4) {
const filename = decodePathSegment(segments.at(-1) ?? "");
if (filename) {
return filename;
}
}
const fallbackSegments = segments.length > 2 ? segments.slice(2) : segments;
const path = fallbackSegments.map((segment) => decodePathSegment(segment) ?? segment);
return ["github.com", ...path].join("/");
}
export function gitHubProfileUrl(login: string): string {
+19 -8
View File
@@ -174,7 +174,7 @@ describe("toSanitizedMarkdownHtml links", () => {
it("links http:// URLs", () => {
const html = toSanitizedMarkdownHtml("Visit http://github.com/openclaw");
expect(html).toBe(
'<p>Visit <a href="http://github.com/openclaw" class="markdown-bare-url markdown-github-link" rel="noreferrer noopener" target="_blank">http://github.com/openclaw</a></p>\n',
'<p>Visit <a href="http://github.com/openclaw" class="markdown-bare-url markdown-github-link" title="http://github.com/openclaw" rel="noreferrer noopener" target="_blank">github.com/openclaw</a></p>\n',
);
});
@@ -596,11 +596,14 @@ describe("toSanitizedMarkdownHtml links", () => {
describe("github link marks", () => {
it.each([
["bare autolink", "https://github.com/openclaw/openclaw/pull/3434", "openclaw/openclaw#3434"],
["bare pull request", "https://github.com/openclaw/openclaw/pull/3434", "#3434"],
["bare issue", "https://github.com/openclaw/openclaw/issues/3435", "#3435"],
["repository", "https://github.com/openclaw/openclaw", "openclaw/openclaw"],
["repository file", "https://github.com/blader/humanizer/blob/main/SKILL.md", "SKILL.md"],
[
"bare issue autolink",
"https://github.com/openclaw/openclaw/issues/3435",
"openclaw/openclaw#3435",
"other path",
"https://github.com/openclaw/openclaw/actions/runs/123",
"github.com/actions/runs/123",
],
["issue shorthand", "[#3434](https://github.com/openclaw/openclaw/pull/3434)", "#3434"],
["labelled link", "[the fix](https://github.com/openclaw/openclaw/pull/3434)", "the fix"],
@@ -621,12 +624,20 @@ describe("toSanitizedMarkdownHtml links", () => {
),
);
const link = fragment.querySelector<HTMLAnchorElement>("a");
expect(link?.textContent).toBe(
"a-very-long-organization-name/a-very-long-repository-name#3434",
);
expect(link?.textContent).toBe("#3434");
expect(link?.classList.contains("markdown-bare-url")).toBe(true);
});
it("keeps the specific destination addressable after shortening its label", () => {
const input = "https://github.com/blader/humanizer/blob/main/SKILL.md";
const fragment = htmlFragment(toSanitizedMarkdownHtml(input));
const link = fragment.querySelector<HTMLAnchorElement>("a");
expect(link?.classList.contains("markdown-github-link")).toBe(true);
expect(link?.textContent).toBe("SKILL.md");
expect(link?.getAttribute("href")).toBe(input);
expect(link?.getAttribute("title")).toBe(input);
});
it.each([
["a files-tab path", "https://github.com/openclaw/openclaw/pull/3434/files"],
["a commits path", "https://github.com/openclaw/openclaw/pull/3434/commits"],
+4 -8
View File
@@ -3,11 +3,7 @@ import markdownItTaskLists from "markdown-it-task-lists";
import type Token from "markdown-it/lib/token.mjs";
import { t } from "../i18n/index.ts";
import { fileKindForPath, shortestFileLabels } from "./file-kind.ts";
import {
formatGitHubItemReference,
isGitHubItemRootPath,
parseGitHubItemPath,
} from "./github-link-target.ts";
import { formatGitHubLinkLabel } from "./github-link-target.ts";
import {
installAssistantTranscriptRoleImageRenderer,
installAssistantTranscriptRoleMarkdown,
@@ -520,7 +516,6 @@ export function createMarkdownParser(): MarkdownIt {
const generatedUrlLabel = open.markup === "linkify" || open.markup === "autolink";
const host = url.hostname.toLowerCase();
const githubLink = host === "github.com" || host === "www.github.com";
const itemTarget = githubLink ? parseGitHubItemPath(url) : null;
if (generatedUrlLabel) {
open.attrJoin("class", BARE_URL_CLASS);
}
@@ -542,8 +537,9 @@ export function createMarkdownParser(): MarkdownIt {
break;
}
}
if (generatedUrlLabel && itemTarget && labelToken && isGitHubItemRootPath(url)) {
labelToken.content = formatGitHubItemReference(itemTarget);
if (generatedUrlLabel && labelToken) {
labelToken.content = formatGitHubLinkLabel(url);
open.attrSet("title", href ?? url.href);
}
}
}
+12 -24
View File
@@ -102,7 +102,7 @@ async function openPullPreviewPage(): Promise<{
});
await page.goto(`${server.baseUrl}chat`);
const pullLink = page.getByRole("link", { name: "openclaw/openclaw#99816" });
const pullLink = page.locator('a.markdown-github-link[href$="/pull/99816"]');
const card = page.locator(".github-link-hovercard");
await pullLink.waitFor({ state: "visible" });
return { card, page, pullLink };
@@ -194,6 +194,7 @@ describeControlUiE2e("GitHub link hover cards", () => {
"then https://github.com/openclaw/openclaw/issues/99815.",
"A [missing item](https://github.com/openclaw/openclaw/issues/999999) stays usable.",
"The [repository](https://github.com/openclaw/openclaw) has no item preview.",
"The skill lives at https://github.com/blader/humanizer/blob/main/SKILL.md.",
"Styling notes live in [the docs](https://docs.openclaw.ai/web/control-ui).",
].join(" "),
},
@@ -227,36 +228,23 @@ describeControlUiE2e("GitHub link hover cards", () => {
await expect.poll(() => page.locator("html").getAttribute("data-theme-mode")).toBe("light");
}
const longLink = page.getByRole("link", {
name: "a-very-long-organization-name/a-very-long-repository-name#99817",
});
await page.setViewportSize({ height: 800, width: 360 });
expect(await longLink.evaluate((element) => getComputedStyle(element).lineBreak)).toBe(
"anywhere",
);
const longMessageBox = await longLink
.locator("xpath=ancestor::*[contains(@class, 'chat-text')]")
.boundingBox();
const longLinkBox = await longLink.boundingBox();
expect(longMessageBox).not.toBeNull();
expect(longLinkBox).not.toBeNull();
expect(longLinkBox!.x).toBeGreaterThanOrEqual(longMessageBox!.x);
expect(longLinkBox!.x + longLinkBox!.width).toBeLessThanOrEqual(
longMessageBox!.x + longMessageBox!.width,
);
await page.setViewportSize({ height: 800, width: 1180 });
await expect
.poll(() => page.getByRole("link", { name: "#99817" }).getAttribute("href"))
.toBe(
"https://github.com/a-very-long-organization-name/a-very-long-repository-name/issues/99817",
);
await expect
.poll(() => page.getByRole("link", { name: "SKILL.md" }).getAttribute("href"))
.toBe("https://github.com/blader/humanizer/blob/main/SKILL.md");
const pullLink = page.getByRole("link", { name: "openclaw/openclaw#99816" });
const pullLink = page.locator('a.markdown-github-link[href$="/pull/99816"]');
// The mark carries the link signal at rest, so the underline only returns on
// hover. Non-GitHub links keep the base underline, which keeps the rule scoped.
const decorationLine = (link: Locator) =>
link.evaluate((element) => getComputedStyle(element).textDecorationLine);
expect(await decorationLine(pullLink)).toBe("none");
expect(await decorationLine(page.getByRole("link", { name: "the docs" }))).toBe("underline");
await pullLink.hover();
await expect.poll(() => decorationLine(pullLink)).toBe("underline");
const card = page.locator(".github-link-hovercard");
await expectText(card, "Merged");
await expectText(card, "openclaw/openclaw #99816");
@@ -272,7 +260,7 @@ describeControlUiE2e("GitHub link hover cards", () => {
expect(pullBox!.x + pullBox!.width).toBeLessThanOrEqual(1180);
expect(pullBox!.y + pullBox!.height).toBeLessThanOrEqual(800);
const issueLink = page.getByRole("link", { name: "openclaw/openclaw#99815" });
const issueLink = page.locator('a.markdown-github-link[href$="/issues/99815"]');
await issueLink.hover();
await expectText(card, "Keep hover previews compact");
await expectText(card, "octocat");
@@ -22,20 +22,20 @@ function readChatCss(): string {
}
// The three shapes the parser can produce, all carrying the same mark:
// a bare item URL whose label it rewrites to owner/repo#number, a bare URL to
// a bare item URL whose label it rewrites to #number, a compact fallback for
// any other GitHub path, and an authored label. Only the first two carry
// markdown-bare-url, so the sweep covers both wrap regimes.
const LINK_FORMS = [
{
className: "markdown-bare-url markdown-github-link",
id: "human-ref",
label: "openclaw/openclaw#123309",
label: "#123309",
lead: "then follow-up tracked in ",
},
{
className: "markdown-bare-url markdown-github-link",
id: "bare-url",
label: "https://github.com/openclaw/openclaw/blob/main/ui/src/styles/chat/text.css#L254",
label: "text.css",
lead: "then the owning rule lives at ",
},
{
-7
View File
@@ -529,9 +529,6 @@
-webkit-mask: var(--github-mark) center / contain no-repeat;
}
/* The glyph already marks these as links; a resting underline doubles the
signal and clutters dense URL text. Hover restores it as the affordance,
like the file-link convention below. */
.chat-text :where(a.markdown-github-link) {
/* The mark is painted out of flow with its space reserved here, so nothing
inside the anchor precedes the label: an in-flow atomic mark carries a soft
@@ -543,10 +540,6 @@
text-decoration: none;
}
.chat-text :where(a.markdown-github-link:hover) {
text-decoration: underline;
}
/* The inherited `overflow-wrap: anywhere` breaks a word only once every other
option is gone, so a long URL is pushed whole onto the next line, leaving the
line it should have filled ragged. Unconditional break points make the URL