fix(webui): refine inline token highlights (#5241)

This commit is contained in:
chengyongru 2026-08-04 16:40:41 +08:00 committed by GitHub
parent 2fe135db3e
commit 287fd88fe4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 31 additions and 36 deletions

View File

@ -191,6 +191,7 @@ export function SessionMentionToken({
<a <a
href={`#/chat/${encodeURIComponent(mention.session_key)}`} href={`#/chat/${encodeURIComponent(mention.session_key)}`}
className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
style={{ textDecorationColor: INLINE_TOKEN_HIGHLIGHT_COLOR }}
> >
{token} {token}
</a> </a>

View File

@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const INLINE_TOKEN_HIGHLIGHT_COLOR = "hsl(var(--inline-token-highlight))"; export const INLINE_TOKEN_HIGHLIGHT_COLOR = "var(--inline-token-highlight)";
export function InlineTokenHighlight({ export function InlineTokenHighlight({
children, children,
@ -22,25 +22,12 @@ export function InlineTokenHighlight({
data-testid={testId} data-testid={testId}
title={title} title={title}
className={cn( className={cn(
"relative inline transition-[color,text-shadow] duration-150", "relative inline font-[550] transition-colors duration-150",
className, className,
)} )}
style={{ style={{ color }}
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
> >
{children} {children}
</span> </span>
); );
} }
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}

View File

@ -618,6 +618,7 @@ export default function MarkdownTextRenderer({
<a <a
href={sessionHref} href={sessionHref}
className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60" className="rounded-sm underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
style={{ textDecorationColor: INLINE_TOKEN_HIGHLIGHT_COLOR }}
> >
<InlineTokenHighlight color={INLINE_TOKEN_HIGHLIGHT_COLOR}> <InlineTokenHighlight color={INLINE_TOKEN_HIGHLIGHT_COLOR}>
{markdownChildren} {markdownChildren}

View File

@ -14,7 +14,6 @@ export function SlashCommandText({
<InlineTokenHighlight <InlineTokenHighlight
testId="message-slash-command" testId="message-slash-command"
color={INLINE_TOKEN_HIGHLIGHT_COLOR} color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className="font-medium"
> >
{command} {command}
</InlineTokenHighlight> </InlineTokenHighlight>

View File

@ -91,9 +91,8 @@ export function UserMessageText({
testId={`message-skill-reference-${segment.name.toLowerCase()}`} testId={`message-skill-reference-${segment.name.toLowerCase()}`}
title={t("message.skill", { name: segment.name })} title={t("message.skill", { name: segment.name })}
color={INLINE_TOKEN_HIGHLIGHT_COLOR} color={INLINE_TOKEN_HIGHLIGHT_COLOR}
className="font-medium"
> >
{segment.text} {segment.name}
</InlineTokenHighlight> </InlineTokenHighlight>
); );
return ( return (

View File

@ -32,7 +32,7 @@
--border: 40 8% 90.5%; --border: 40 8% 90.5%;
--input: 40 8% 90.5%; --input: 40 8% 90.5%;
--ring: 0 0% 3.9%; --ring: 0 0% 3.9%;
--inline-token-highlight: 221 70% 50%; --inline-token-highlight: #ef8e30;
--radius: 0.4375rem; --radius: 0.4375rem;
--sidebar: 40 8% 96.8%; --sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%; --sidebar-foreground: 0 0% 3.9%;
@ -66,7 +66,7 @@
--border: 0 0% 28%; --border: 0 0% 28%;
--input: var(--border); --input: var(--border);
--ring: 0 0% 83.1%; --ring: 0 0% 83.1%;
--inline-token-highlight: 217 92% 72%; --inline-token-highlight: #ef8e30;
--sidebar: var(--card); --sidebar: var(--card);
--sidebar-foreground: 0 0% 98%; --sidebar-foreground: 0 0% 98%;
--sidebar-accent: var(--background); --sidebar-accent: var(--background);

View File

@ -23,6 +23,9 @@ describe("MarkdownTextRenderer", () => {
const link = screen.getByRole("link", { name: "收费设计" }); const link = screen.getByRole("link", { name: "收费设计" });
expect(link).toHaveAttribute("href", "#/chat/websocket%3Apricing"); expect(link).toHaveAttribute("href", "#/chat/websocket%3Apricing");
expect(link).not.toHaveAttribute("target"); expect(link).not.toHaveAttribute("target");
expect(link.getAttribute("style")).toContain(
"text-decoration-color: var(--inline-token-highlight)",
);
}); });
it("does not link non-WebUI session references", () => { it("does not link non-WebUI session references", () => {

View File

@ -226,12 +226,12 @@ describe("MessageBubble", () => {
const command = screen.getByTestId("message-slash-command"); const command = screen.getByTestId("message-slash-command");
expect(command).toHaveTextContent("/model"); expect(command).toHaveTextContent("/model");
expect(command).toHaveClass( expect(command).toHaveClass(
"font-medium", "font-[550]",
"transition-[color,text-shadow]", "transition-colors",
"duration-150", "duration-150",
); );
expect(command).not.toHaveClass("font-mono", "font-semibold"); expect(command).not.toHaveClass("font-mono");
expect(command.getAttribute("style")).toContain("text-shadow"); expect(command.getAttribute("style")).not.toContain("text-shadow");
expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)"); expect(command.getAttribute("style")).toContain("var(--inline-token-highlight)");
expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/); expect(command.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
expect(command.parentElement).toHaveTextContent("/model gpt-5"); expect(command.parentElement).toHaveTextContent("/model gpt-5");
@ -299,16 +299,17 @@ describe("MessageBubble", () => {
); );
const skill = screen.getByTestId("message-skill-reference-github"); const skill = screen.getByTestId("message-skill-reference-github");
expect(skill).toHaveTextContent("$github"); expect(skill).toHaveTextContent(/^github$/);
expect(skill).toHaveClass( expect(skill).toHaveClass(
"font-medium", "font-[550]",
"transition-[color,text-shadow]", "transition-colors",
"duration-150", "duration-150",
); );
expect(skill.getAttribute("style")).not.toContain("text-shadow");
expect(skill.getAttribute("style")).toContain("var(--inline-token-highlight)"); expect(skill.getAttribute("style")).toContain("var(--inline-token-highlight)");
expect(skill.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/); expect(skill.className).not.toMatch(/(?:^|\s)(?:bg-|border|ring|rounded)/);
expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom"); expect(screen.getByTestId("message-cli-mention-zoom")).toHaveTextContent("@zoom");
expect(skill.parentElement).toHaveTextContent("Ask $github to review this with @zoom"); expect(skill.parentElement).toHaveTextContent("Ask github to review this with @zoom");
}); });
it("highlights well-formed skill references and leaves a bare marker plain", () => { it("highlights well-formed skill references and leaves a bare marker plain", () => {
@ -321,13 +322,13 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} />); render(<MessageBubble message={message} />);
expect(screen.getByTestId("message-skill-reference-unknown")).toHaveTextContent("$unknown"); expect(screen.getByTestId("message-skill-reference-unknown")).toHaveTextContent(/^unknown$/);
expect(screen.getByTestId("message-skill-reference-blocked-skill")) expect(screen.getByTestId("message-skill-reference-blocked-skill"))
.toHaveTextContent("$blocked-skill"); .toHaveTextContent(/^blocked-skill$/);
const references = screen.getAllByTestId(/^message-skill-reference-/); const references = screen.getAllByTestId(/^message-skill-reference-/);
expect(references).toHaveLength(2); expect(references).toHaveLength(2);
expect(references[0].parentElement) expect(references[0].parentElement)
.toHaveTextContent("Try $unknown or $blocked-skill and $"); .toHaveTextContent("Try unknown or blocked-skill and $");
}); });
it("renders fork control in completed assistant action rows", () => { it("renders fork control in completed assistant action rows", () => {
@ -444,10 +445,11 @@ describe("MessageBubble", () => {
const token = screen.getByTestId("message-cli-mention-zoom"); const token = screen.getByTestId("message-cli-mention-zoom");
expect(token).toHaveTextContent("@zoom"); expect(token).toHaveTextContent("@zoom");
expect(token).toHaveAttribute("title", "CLI app: Zoom"); expect(token).toHaveAttribute("title", "CLI app: Zoom");
expect(token).toHaveClass("font-[550]");
expect(token.className).not.toContain("rounded"); expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-"); expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #0B5CFF"); expect(token.getAttribute("style")).toContain("color: #0B5CFF");
expect(token.getAttribute("style")).toContain("text-shadow"); expect(token.getAttribute("style")).not.toContain("text-shadow");
expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument(); expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument();
expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument(); expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument();
expect(screen.getByText(/not @krita/)).toBeInTheDocument(); expect(screen.getByText(/not @krita/)).toBeInTheDocument();
@ -531,6 +533,9 @@ describe("MessageBubble", () => {
expect(token).toHaveTextContent("@收费设计"); expect(token).toHaveTextContent("@收费设计");
expect(token).toHaveAttribute("title", "Session: 收费设计"); expect(token).toHaveAttribute("title", "Session: 收费设计");
expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing"); expect(token.closest("a")).toHaveAttribute("href", "#/chat/websocket%3Apricing");
expect(token.closest("a")?.getAttribute("style")).toContain(
"text-decoration-color: var(--inline-token-highlight)",
);
}); });
it("copies completed assistant replies from the action row", async () => { it("copies completed assistant replies from the action row", async () => {

View File

@ -1887,12 +1887,12 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue("meeting in @gimp"); expect(input).toHaveValue("meeting in @gimp");
const token = screen.getByTestId("composer-cli-mention-gimp"); const token = screen.getByTestId("composer-cli-mention-gimp");
expect(token).toHaveTextContent("@gimp"); expect(token).toHaveTextContent("@gimp");
expect(token.className).not.toContain("font-semibold"); expect(token).toHaveClass("font-[550]");
expect(token.className).not.toContain("zoom-in"); expect(token.className).not.toContain("zoom-in");
expect(token.className).not.toContain("px-"); expect(token.className).not.toContain("px-");
expect(token.className).not.toContain("mx-"); expect(token.className).not.toContain("mx-");
expect(token.getAttribute("style")).toContain("color: #5C5543"); expect(token.getAttribute("style")).toContain("color: #5C5543");
expect(token.getAttribute("style")).toContain("text-shadow"); expect(token.getAttribute("style")).not.toContain("text-shadow");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument(); expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
const logo = screen.getByTestId("composer-cli-mention-logo-gimp"); const logo = screen.getByTestId("composer-cli-mention-logo-gimp");
expect(logo.className).toContain("top-1/2"); expect(logo.className).toContain("top-1/2");

View File

@ -868,7 +868,7 @@ describe("ThreadShell", () => {
expectSendMessageWithTurn(client, "skill-reference", "Use $github for this"), expectSendMessageWithTurn(client, "skill-reference", "Use $github for this"),
); );
expect(screen.getByTestId("message-skill-reference-github")) expect(screen.getByTestId("message-skill-reference-github"))
.toHaveTextContent("$github"); .toHaveTextContent(/^github$/);
}); });
it("clears the old thread when the active session is removed", async () => { it("clears the old thread when the active session is removed", async () => {