mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
feat(ui): render GitHub issue and PR links as reference chips (#139653)
* feat(ui): render GitHub issue and PR links as reference chips Bare GitHub issue and pull-request URLs in chat previously shortened to a plain "#N" label behind the generic GitHub mark, and any subpath, query, or fragment variant kept the full URL visible. They now render as a compact chip with an issue or pull-request Octicon and "#N", including files, commits, comment, and diff-anchor links; authored "#N" and "owner/repo#N" shorthands become the same chip. The exact destination stays in href and the tooltip, and the existing hover card keeps opening from the chip. Custom labels, repository and blob links, image-only links, and code spans keep their current presentation. * fix(ui): normalize github link chip test tuple shapes it.each requires one consistent tuple shape across all rows; mixing 3- and 4-element arrays made TypeScript reject the callback signature.
This commit is contained in:
parent
a00630d2e6
commit
127ecdd810
7 changed files with 291 additions and 41 deletions
|
|
@ -35,3 +35,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## GitHub Octicons
|
||||
|
||||
The Control UI bundles the `issue-opened-16` and `git-pull-request-16` icon paths.
|
||||
|
||||
- Upstream: https://github.com/primer/octicons
|
||||
- License: MIT
|
||||
- Copyright: Copyright (c) 2026 GitHub Inc.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
|
|||
|
|
@ -549,7 +549,7 @@ are Gateway settings and remain available in every browser.
|
|||
- A previous run's error banner clears when Chat adopts a new run or history confirms a newer successful run. Retiring the banner does not erase recorded diagnostics. A late error from the same run can remain visible beside its delivered answer; reconnecting or refreshing metadata alone does not establish recovery.
|
||||
- A saved assistant answer replaces its live stream without waiting for the run to finish. Refreshing history or reconnecting while that reply finishes does not add another copy of the saved answer. Later streamed continuations remain visible. Remote workspace reconciliation can keep the working indicator and Stop control active after the answer appears; a later reconciliation failure remains visible beside the answer.
|
||||
- Scroll up to read earlier messages without following incoming output. Sending a message, submitting a transcript command such as `/help`, or using the down-arrow button returns to the latest message, including when the composer or progress card resizes. Scrolling manually interrupts that movement or a restored scroll position; keys handled by text fields or media controls do not. Messages continue to reserve their space as full text, images, and tool output load.
|
||||
- Links to `github.com` in chat messages — yours and the agent's — carry a small GitHub mark before their text, whether the message wrote a bare URL, a `[#3434](…)` shorthand, or any other label. The mark is drawn from the bundled icon set, never fetched from the network, and is decorative only: it is skipped for image-only links such as badges, never appears inside code spans or code blocks, is not read by screen readers, and is not part of copied text.
|
||||
- Links to `github.com` in chat messages — yours and the agent's — show issue and pull-request references as compact chips with an issue or pull-request icon. Bare item URLs show `#number`, including links to files, commits, comments, and diff anchors; matching `#number` or `owner/repo#number` labels also become chips. The tooltip preserves the exact destination, and the hover card opens that same link. Other GitHub links and custom labels keep the GitHub mark. Icons are bundled, never fetched from the network, and decorative only: they are skipped for image-only links such as badges, never appear inside code spans or code blocks, are not read by screen readers, and are not part of copied text.
|
||||
- Hovering or keyboard-focusing a public GitHub issue or pull request link shows its state, title, author, recent activity, comments, and change statistics. The connected Gateway fetches and caches public metadata without changing the link target, including when the UI uses a remote Gateway. The card's title and repository reference open the exact link you hovered or focused, including comment fragments and query parameters, even when another link to the same item has already filled the cache. Previews use the selected agent's configured GitHub identity, inheriting the system identity when there is no agent override. Without a managed identity, they retain the explicit Control UI GitHub credential, then the shared Gateway process-environment fallback; public previews still work anonymously without credentials. Configured managed identities fail visibly instead of switching accounts, and authenticated previews remain restricted to public repositories. Failures show a short explanation below **GitHub preview unavailable**, including rate-limit retry timing when GitHub supplies it.
|
||||
- Talk through browser realtime sessions. OpenAI supports browser WebRTC and Gateway-relayed provider WebSockets, Google Live uses a constrained one-use browser token over WebSocket, and backend-only realtime voice plugins use Gateway relay. Video-capable browser sessions can choose a device-local camera in Settings or flip cameras from the live preview; the browser captures JPEG frames for the realtime provider without streaming camera video through the Gateway. Client-owned provider sessions start with `talk.client.create`; Gateway relay sessions start with `talk.session.create`. The relay keeps provider credentials on the Gateway while the browser streams microphone PCM through `talk.session.appendAudio`, forwards provider delegations or `openclaw_agent_consult` tool calls through Gateway policy and the larger configured OpenClaw model, and routes active-run voice steering through `talk.client.steer` or `talk.session.steer`. Browser WebRTC GPT-Live delegates on the Gateway-owned sideband, but each delegation has the same spoken-confirmation gate and browser-owned `talk.client.steer` lifecycle; a newer spoken task can also supersede the running delegation. Gateway-relayed GPT-Live uses the normal relay consult and steering path. Configure the realtime provider, model, and speaker voice on **Settings → Talk**, whose pickers come from `talk.catalog` and show whether the selection is ready to use.
|
||||
- Stream tool calls and live tool output cards in Chat (agent events). Tool activity renders as kind-aware rows: shell commands show the syntax-highlighted command with terminal-style output; supported edit and write calls show bounded inline diffs with source syntax highlighting, line numbers when available, and `+added -removed` stats; and consecutive calls collapse into a summary such as "Ran 13 commands, read 6 files, edited 9 files". While a run is live, the newest running call names the group header. Expand a row to inspect its remaining arguments and raw output.
|
||||
|
|
|
|||
|
|
@ -753,28 +753,110 @@ describe("toSanitizedMarkdownHtml links", () => {
|
|||
|
||||
describe("github link marks", () => {
|
||||
it.each([
|
||||
["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 pull request", "https://github.com/openclaw/openclaw/pull/3434", "#3434", "pull"],
|
||||
["bare issue", "https://github.com/openclaw/openclaw/issues/3435", "#3435", "issue"],
|
||||
["autolink", "<https://github.com/openclaw/openclaw/pull/3434>", "#3434", "pull"],
|
||||
["bare www item", "https://www.github.com/openclaw/openclaw/issues/3435", "#3435", "issue"],
|
||||
["repository", "https://github.com/openclaw/openclaw", "openclaw/openclaw", undefined],
|
||||
[
|
||||
"repository file",
|
||||
"https://github.com/blader/humanizer/blob/main/SKILL.md",
|
||||
"SKILL.md",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"other path",
|
||||
"https://github.com/openclaw/openclaw/actions/runs/123",
|
||||
"github.com/actions/runs/123",
|
||||
undefined,
|
||||
],
|
||||
["issue shorthand", "[#3434](https://github.com/openclaw/openclaw/pull/3434)", "#3434"],
|
||||
["labelled link", "[the fix](https://github.com/openclaw/openclaw/pull/3434)", "the fix"],
|
||||
["www host", "[the fix](https://www.github.com/openclaw/openclaw/pull/3434)", "the fix"],
|
||||
["http scheme", "[the fix](http://github.com/openclaw/openclaw/pull/3434)", "the fix"],
|
||||
["list item", "- [the fix](https://github.com/openclaw/openclaw/pull/3434)", "the fix"],
|
||||
])("marks %s", (_kind, input, expectedText) => {
|
||||
[
|
||||
"pull shorthand",
|
||||
"[#3434](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"#3434",
|
||||
"pull",
|
||||
],
|
||||
[
|
||||
"issue shorthand",
|
||||
"[#3434](https://github.com/openclaw/openclaw/issues/3434)",
|
||||
"#3434",
|
||||
"issue",
|
||||
],
|
||||
[
|
||||
"repository shorthand",
|
||||
"[openclaw/openclaw#3434](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"openclaw/openclaw#3434",
|
||||
"pull",
|
||||
],
|
||||
[
|
||||
"shorthand with authored tooltip",
|
||||
'[#3434](https://github.com/openclaw/openclaw/pull/3434 "A pull request")',
|
||||
"#3434",
|
||||
"pull",
|
||||
],
|
||||
[
|
||||
"labelled link",
|
||||
"[the fix](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"the fix",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"www host",
|
||||
"[the fix](https://www.github.com/openclaw/openclaw/pull/3434)",
|
||||
"the fix",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"http scheme",
|
||||
"[the fix](http://github.com/openclaw/openclaw/pull/3434)",
|
||||
"the fix",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"list item",
|
||||
"- [the fix](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"the fix",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"wrong number",
|
||||
"[#3435](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"#3435",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"wrong repository",
|
||||
"[other/project#3434](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"other/project#3434",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"padded label",
|
||||
"[ #3434 ](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
" #3434 ",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"code-span label",
|
||||
"[`#3434`](https://github.com/openclaw/openclaw/pull/3434)",
|
||||
"#3434",
|
||||
undefined,
|
||||
],
|
||||
])("marks %s", (_kind, input, expectedText, expectedKind) => {
|
||||
const fragment = htmlFragment(toSanitizedMarkdownHtml(input));
|
||||
const link = fragment.querySelector<HTMLAnchorElement>("a");
|
||||
expect(link?.classList.contains("markdown-github-link")).toBe(true);
|
||||
expect(link?.textContent).toBe(expectedText);
|
||||
expect(link?.classList.contains("markdown-github-item")).toBe(Boolean(expectedKind));
|
||||
expect(link?.getAttribute("data-github-kind")).toBe(expectedKind ?? null);
|
||||
if (expectedKind) {
|
||||
expect(link?.getAttribute("title")).toBe(link?.getAttribute("href"));
|
||||
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
|
||||
expect(link?.getAttribute("target")).toBe("_blank");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps long generated item references breakable after compaction", () => {
|
||||
it("compacts long generated item references into chips", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(
|
||||
"https://github.com/a-very-long-organization-name/a-very-long-repository-name/issues/3434",
|
||||
|
|
@ -783,6 +865,7 @@ describe("toSanitizedMarkdownHtml links", () => {
|
|||
const link = fragment.querySelector<HTMLAnchorElement>("a");
|
||||
expect(link?.textContent).toBe("#3434");
|
||||
expect(link?.classList.contains("markdown-bare-url")).toBe(true);
|
||||
expect(link?.classList.contains("markdown-github-item")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the specific destination addressable after shortening its label", () => {
|
||||
|
|
@ -804,12 +887,14 @@ describe("toSanitizedMarkdownHtml links", () => {
|
|||
],
|
||||
["a review comment query", "https://github.com/openclaw/openclaw/pull/3434?tab=files"],
|
||||
["a diff anchor", "https://github.com/openclaw/openclaw/pull/3434/files#diff-abc123"],
|
||||
])("keeps the specific destination visible for %s", (_kind, input) => {
|
||||
])("keeps the specific destination in the chip href and tooltip for %s", (_kind, input) => {
|
||||
const fragment = htmlFragment(toSanitizedMarkdownHtml(input));
|
||||
const link = fragment.querySelector<HTMLAnchorElement>("a");
|
||||
expect(link?.classList.contains("markdown-github-link")).toBe(true);
|
||||
expect(link?.textContent).toBe(input);
|
||||
expect(link?.classList.contains("markdown-github-item")).toBe(true);
|
||||
expect(link?.textContent).toBe("#3434");
|
||||
expect(link?.getAttribute("href")).toBe(input);
|
||||
expect(link?.getAttribute("title")).toBe(input);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -818,15 +903,19 @@ describe("toSanitizedMarkdownHtml links", () => {
|
|||
["github in query", "[docs](https://example.com/?to=https://github.com/openclaw)"],
|
||||
["subdomain host", "[pages](https://openclaw.github.io/openclaw)"],
|
||||
["image-only link", "[](https://github.com/openclaw)"],
|
||||
["image-only item", "[](https://github.com/o/r/pull/3434)"],
|
||||
["lookalike item", "https://github.com.example.com/o/r/pull/3434"],
|
||||
["non-github shorthand", "[#3434](https://example.com/o/r/pull/3434)"],
|
||||
])("leaves %s unmarked", (_kind, input) => {
|
||||
const fragment = htmlFragment(toSanitizedMarkdownHtml(input));
|
||||
expect(fragment.querySelector("a.markdown-github-link")).toBeNull();
|
||||
expect(fragment.querySelector("a.markdown-github-item, a[data-github-kind]")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves github urls inside code untouched", () => {
|
||||
const fragment = htmlFragment(
|
||||
toSanitizedMarkdownHtml(
|
||||
"`https://github.com/openclaw/openclaw`\n\n```\nhttps://github.com/openclaw/openclaw\n```",
|
||||
"`https://github.com/openclaw/openclaw`\n\n```\nhttps://github.com/openclaw/openclaw\n```\n\n`https://github.com/o/r/issues/3434`\n\n```\nhttps://github.com/o/r/pull/3434\n```",
|
||||
),
|
||||
);
|
||||
expect(fragment.querySelector("a")).toBeNull();
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ const CJK_RE = new RegExp(
|
|||
"[\\u2E80-\\u2FFF\\u3000-\\u303F\\u3040-\\u309F\\u30A0-\\u30FF\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uAC00-\\uD7AF\\uF900-\\uFAFF\\uFF01-\\uFF60]",
|
||||
);
|
||||
|
||||
// Anchors carrying this class get the decorative GitHub mark painted by CSS
|
||||
// (styles/chat/text.css). The mark is never emitted as markup so it stays out
|
||||
// Anchors carrying this class get a decorative GitHub icon painted by CSS
|
||||
// (styles/chat/text.css). The icon is never emitted as markup so it stays out
|
||||
// of the accessibility tree and out of copied text.
|
||||
const GITHUB_LINK_CLASS = "markdown-github-link";
|
||||
// Marks anchors whose visible text is the URL itself, which CSS may break at
|
||||
|
|
@ -110,10 +110,6 @@ function parseWebLinkHref(href: string): URL | null {
|
|||
|
||||
function formatGitHubLinkLabel(url: URL): string {
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const item = parseGitHubItemPath(url);
|
||||
if (item) {
|
||||
return segments.length === 4 && !url.search && !url.hash ? `#${item.number}` : url.href;
|
||||
}
|
||||
if (segments.length === 2) {
|
||||
return segments.map((segment) => decodeGitHubPathSegment(segment) ?? segment).join("/");
|
||||
}
|
||||
|
|
@ -542,10 +538,28 @@ export function createMarkdownParser(): MarkdownItParser {
|
|||
}
|
||||
if (githubLink && labelToken) {
|
||||
open.attrJoin("class", GITHUB_LINK_CLASS);
|
||||
}
|
||||
if (githubLink && generatedUrlLabel && labelToken) {
|
||||
labelToken.content = formatGitHubLinkLabel(url);
|
||||
open.attrSet("title", href ?? url.href);
|
||||
const item = parseGitHubItemPath(url);
|
||||
const label =
|
||||
labelToken.type === "text" &&
|
||||
children[index + 1] === labelToken &&
|
||||
children[index + 2]?.type === "link_close"
|
||||
? labelToken.content
|
||||
: null;
|
||||
const itemChip =
|
||||
item &&
|
||||
(generatedUrlLabel ||
|
||||
label === `#${item.number}` ||
|
||||
label === `${item.owner}/${item.repo}#${item.number}`);
|
||||
if (itemChip) {
|
||||
open.attrJoin("class", "markdown-github-item");
|
||||
open.attrSet("data-github-kind", item.kind);
|
||||
}
|
||||
if (generatedUrlLabel) {
|
||||
labelToken.content = item ? `#${item.number}` : formatGitHubLinkLabel(url);
|
||||
}
|
||||
if (generatedUrlLabel || itemChip) {
|
||||
open.attrSet("title", href);
|
||||
}
|
||||
}
|
||||
if (!githubLink && labelToken && state.env.linkFavicons) {
|
||||
const favicon = new state.Token("link_favicon", "img", 0);
|
||||
|
|
|
|||
|
|
@ -21,25 +21,39 @@ function readChatCss(): string {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
// The three shapes the parser can produce, all carrying the same mark:
|
||||
// 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.
|
||||
// Item chips stay atomic; other GitHub links keep normal label wrapping.
|
||||
const LINK_FORMS = [
|
||||
{
|
||||
className: "markdown-bare-url markdown-github-link",
|
||||
id: "human-ref",
|
||||
className: "markdown-bare-url markdown-github-link markdown-github-item",
|
||||
kind: "issue",
|
||||
id: "issue",
|
||||
label: "#123309",
|
||||
lead: "then follow-up tracked in ",
|
||||
},
|
||||
{
|
||||
className: "markdown-bare-url markdown-github-link markdown-github-item",
|
||||
kind: "pull",
|
||||
id: "pull",
|
||||
label: "#3434",
|
||||
lead: "then the fix is in ",
|
||||
},
|
||||
{
|
||||
className: "markdown-github-link markdown-github-item",
|
||||
kind: "pull",
|
||||
id: "repository-ref",
|
||||
label: "openclaw/openclaw#3434",
|
||||
lead: "then see ",
|
||||
},
|
||||
{
|
||||
className: "markdown-bare-url markdown-github-link",
|
||||
kind: "",
|
||||
id: "bare-url",
|
||||
label: "text.css",
|
||||
lead: "then the owning rule lives at ",
|
||||
},
|
||||
{
|
||||
className: "markdown-github-link",
|
||||
kind: "",
|
||||
id: "authored",
|
||||
label: "the sibling chip rule",
|
||||
lead: "then see ",
|
||||
|
|
@ -50,9 +64,9 @@ function fixtureDocument(themeMode: "dark" | "light"): string {
|
|||
const themeAttributes =
|
||||
themeMode === "light" ? `data-theme="light" data-theme-mode="light"` : `data-theme="dark"`;
|
||||
const columns = LINK_FORMS.map(
|
||||
({ className, id, label, lead }) => `
|
||||
({ className, kind, id, label, lead }) => `
|
||||
<div class="chat-text" id="column-${id}">Reproduce the failing run and read the notes
|
||||
first, ${lead}<a id="${id}" class="${className}" href="https://github.com/openclaw/openclaw"
|
||||
first, ${lead}<a id="${id}" class="${className}" ${kind ? `data-github-kind="${kind}"` : ""} href="https://github.com/openclaw/openclaw"
|
||||
>${label}</a> before landing the fix.</div>`,
|
||||
).join("");
|
||||
return `<!doctype html><html ${themeAttributes}><head><style>${readChatCss()}</style></head>
|
||||
|
|
@ -62,6 +76,7 @@ function fixtureDocument(themeMode: "dark" | "light"): string {
|
|||
type WrapSample = {
|
||||
readonly columnWidth: number;
|
||||
readonly fragments: number;
|
||||
readonly labelFragments: number;
|
||||
readonly labelStartsMarkLine: boolean;
|
||||
readonly markLineTop: number;
|
||||
};
|
||||
|
|
@ -105,9 +120,11 @@ async function probeWrap(
|
|||
labelRange.setStart(labelText, 0);
|
||||
labelRange.setEnd(labelText, 1);
|
||||
const labelStart = labelRange.getBoundingClientRect();
|
||||
labelRange.selectNodeContents(link);
|
||||
collected.push({
|
||||
columnWidth,
|
||||
fragments: link.getClientRects().length,
|
||||
labelFragments: labelRange.getClientRects().length,
|
||||
labelStartsMarkLine: Math.abs(labelStart.top - linkStart.top) < 2,
|
||||
markLineTop: Math.round(linkStart.top),
|
||||
});
|
||||
|
|
@ -147,26 +164,28 @@ afterAll(async () => {
|
|||
|
||||
describeGitHubLinkPresentation("chat GitHub link presentation", () => {
|
||||
it.each(["light", "dark"] as const)(
|
||||
"keeps the GitHub mark on its label's line at every column width in %s",
|
||||
"keeps GitHub icons with their labels and item chips atomic at every column width in %s",
|
||||
async (themeMode) => {
|
||||
const samples = await probeWrap(themeMode);
|
||||
for (const { id } of LINK_FORMS) {
|
||||
for (const { id, kind } of LINK_FORMS) {
|
||||
const collected = samples[id] ?? [];
|
||||
// Vacuity guard: the reference must actually move between lines across
|
||||
// the sweep, or the assertion below passes on prose that never wraps.
|
||||
expect(new Set(collected.map((sample) => sample.markLineTop)).size).toBeGreaterThan(1);
|
||||
const stranded = collected.filter((sample) => !sample.labelStartsMarkLine);
|
||||
expect({ id, stranded }).toEqual({ id, stranded: [] });
|
||||
if (kind) {
|
||||
expect(
|
||||
collected.filter((sample) => sample.fragments !== 1 || sample.labelFragments !== 1),
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps GitHub links breaking across lines instead of moving whole", async () => {
|
||||
const samples = await probeWrap("dark");
|
||||
// The file-link chip answers the same invariant by making the whole anchor
|
||||
// atomic. That is the wrong answer here: an atomic anchor never fragments,
|
||||
// so a long URL would move to the next line rather than break inside it and
|
||||
// leave the line it should have filled ragged. Both forms must still split.
|
||||
// Non-item links still fill the line they start on rather than moving whole.
|
||||
for (const id of ["bare-url", "authored"]) {
|
||||
const collected = samples[id] ?? [];
|
||||
expect({ id, splits: collected.some((sample) => sample.fragments > 1) }).toEqual({
|
||||
|
|
@ -175,4 +194,59 @@ describeGitHubLinkPresentation("chat GitHub link presentation", () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["light", "dark"] as const)(
|
||||
"paints distinct kind icons and visible hover and keyboard focus states in %s",
|
||||
async (themeMode) => {
|
||||
const fixtureFile = path.join(fixtureDirectory, `${themeMode}-interaction.html`);
|
||||
fs.writeFileSync(fixtureFile, fixtureDocument(themeMode), "utf8");
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto(`file://${fixtureFile}`);
|
||||
const masks: string[] = [];
|
||||
for (const id of ["issue", "pull"]) {
|
||||
const chip = page.locator(`#${id}`);
|
||||
const idle = await chip.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const icon = getComputedStyle(element, "::before");
|
||||
return {
|
||||
background: style.backgroundColor,
|
||||
decoration: style.textDecorationLine,
|
||||
iconColor: icon.backgroundColor,
|
||||
color: style.color,
|
||||
mask: icon.maskImage,
|
||||
};
|
||||
});
|
||||
expect(idle.decoration).toBe("none");
|
||||
expect(idle.iconColor).toBe(idle.color);
|
||||
expect(idle.mask).toContain("data:image/svg+xml");
|
||||
masks.push(idle.mask);
|
||||
await chip.hover();
|
||||
const hover = await chip.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
decoration: getComputedStyle(element).textDecorationLine,
|
||||
}));
|
||||
expect(hover.background).not.toBe(idle.background);
|
||||
expect(hover.decoration).toBe("underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await chip.focus();
|
||||
expect(await chip.evaluate((element) => element.matches(":focus-visible"))).toBe(true);
|
||||
expect(
|
||||
await chip.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
decoration: getComputedStyle(element).textDecorationLine,
|
||||
})),
|
||||
).toEqual(hover);
|
||||
await chip.evaluate((element) => element.blur());
|
||||
}
|
||||
const genericMask = await page
|
||||
.locator("#authored")
|
||||
.evaluate((element) => getComputedStyle(element, "::before").maskImage);
|
||||
expect(new Set([...masks, genericMask]).size).toBe(3);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -529,8 +529,8 @@
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* GitHub links carry the brand mark so a bare URL and a "[#3434]" shorthand read
|
||||
as the same destination. Painted as a masked ::before rather than markup: it
|
||||
/* GitHub links carry a brand mark or an issue/pull-request icon. Painted as a
|
||||
masked ::before rather than markup: it
|
||||
stays out of the accessibility tree and out of copied text, and currentColor
|
||||
keeps it on the link color in every theme. Sized in em so it tracks
|
||||
--chat-text-size (14px default) and the user's text-scale setting. */
|
||||
|
|
@ -570,6 +570,47 @@
|
|||
line-break: anywhere;
|
||||
}
|
||||
|
||||
/* Item references share the titled session chip's surface. The whole chip is
|
||||
atomic so its in-flow icon cannot wrap away from the number. */
|
||||
.chat-text a.markdown-github-item {
|
||||
display: inline-block;
|
||||
position: static;
|
||||
padding: 0.08em 0.45em 0.08em 0.4em;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 72%, var(--bg-muted));
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
line-break: auto;
|
||||
}
|
||||
|
||||
.chat-text a.markdown-github-item:hover,
|
||||
.chat-text a.markdown-github-item:focus-visible {
|
||||
background: color-mix(in srgb, var(--accent-subtle) 92%, var(--bg-muted));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-text a.markdown-github-item::before {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
margin-inline-end: 0.3em;
|
||||
vertical-align: -0.12em;
|
||||
mask: var(--github-item-icon) center / contain no-repeat;
|
||||
-webkit-mask: var(--github-item-icon) center / contain no-repeat;
|
||||
}
|
||||
|
||||
/* GitHub Octicons issue-opened-16 and git-pull-request-16 (MIT). */
|
||||
.chat-text a.markdown-github-item[data-github-kind="issue"]::before {
|
||||
--github-item-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z'/%3E%3Cpath d='M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-text a.markdown-github-item[data-github-kind="pull"]::before {
|
||||
--github-item-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
:is(.chat-text, .sidebar-markdown, .chat-reply-attribution) a.markdown-session-link {
|
||||
display: inline;
|
||||
padding: 0.1em 0.2em;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const CURSOR_CASES: readonly CursorCase[] = [
|
|||
{ expected: "pointer", selector: "#new-tab-button" },
|
||||
{ expected: "pointer", selector: "#shadow-new-tab-button", shadow: true },
|
||||
{ expected: "pointer", selector: ".markdown-file-link" },
|
||||
{ expected: "pointer", selector: ".markdown-github-item" },
|
||||
// Semantic cursors remain owned by their components.
|
||||
{ expected: "text", selector: "#plain-text-input" },
|
||||
{ expected: "text", selector: ".chat-pane__session-title-button" },
|
||||
|
|
@ -135,6 +136,7 @@ function fixtureDocument(): string {
|
|||
<div class="session-tokens"><span class="session-tokens__value">12k</span></div>
|
||||
<span class="agent-tools-runtime-chip--more">+3</span>
|
||||
<div class="chat-text"><a class="markdown-file-link">src/index.ts</a></div>
|
||||
<div class="chat-text"><a class="markdown-github-link markdown-github-item" data-github-kind="pull" href="https://github.com/openclaw/openclaw/pull/3434" target="_blank">#3434</a></div>
|
||||
<div id="shadow-policy-host"><template shadowrootmode="open">
|
||||
<style>${dockPanelStyles.cssText}</style>
|
||||
<button id="shadow-new-tab-button" class="rail-header__action" type="button" data-new-tab-action>New tab</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue