diff --git a/src/browser/stories/App.markdown.stories.tsx b/src/browser/stories/App.markdown.stories.tsx index 96605cef21..6b39ad2d71 100644 --- a/src/browser/stories/App.markdown.stories.tsx +++ b/src/browser/stories/App.markdown.stories.tsx @@ -4,6 +4,20 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; import { STABLE_TIMESTAMP, createUserMessage, createAssistantMessage } from "./mockFactory"; +import { expect, waitFor } from "@storybook/test"; + +async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Promise { + await waitFor( + () => { + const messageWindow = canvasElement.querySelector('[data-testid="message-window"]'); + if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") { + throw new Error("Messages not loaded yet"); + } + }, + { timeout: 5000 } + ); +} + import { setupSimpleChatStory } from "./storyHelpers"; export default { @@ -87,6 +101,18 @@ describe('getUser', () => { expect(res.status).toBe(401); }); }); +\`\`\` + +Text code blocks (regression: no phantom trailing blank line after highlighting): + +\`\`\`text +https://github.com/coder/mux/pull/new/chat-autocomplete-b24r +\`\`\` + +Code blocks without language (regression: avoid extra vertical spacing): + +\`\`\` +65d02772b 🤖 feat: Settings-driven model selector with visibility controls \`\`\``; // ═══════════════════════════════════════════════════════════════════════════════ @@ -160,4 +186,56 @@ export const CodeBlocks: AppStory = { } /> ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForChatMessagesLoaded(canvasElement); + + const url = "https://github.com/coder/mux/pull/new/chat-autocomplete-b24r"; + + // Find the highlighted code block containing the URL. + const container = await waitFor( + () => { + const candidates = Array.from(canvasElement.querySelectorAll(".code-block-container")); + const found = candidates.find((el) => el.textContent?.includes(url)); + if (!found) { + throw new Error("URL code block not found"); + } + return found; + }, + { timeout: 5000 } + ); + + // Ensure we capture the post-highlight DOM (Shiki wraps tokens in spans). + await waitFor( + () => { + const hasHighlightedSpans = container.querySelector(".code-line span"); + if (!hasHighlightedSpans) { + throw new Error("Code block not highlighted yet"); + } + }, + { timeout: 5000 } + ); + + const noLangLine = "65d02772b 🤖 feat: Settings-driven model selector with visibility controls"; + + const codeEl = await waitFor( + () => { + const candidates = Array.from( + canvasElement.querySelectorAll(".markdown-content pre > code") + ); + const found = candidates.find((el) => el.textContent?.includes(noLangLine)); + if (!found) { + throw new Error("No-language code block not found"); + } + return found; + }, + { timeout: 5000 } + ); + + const style = window.getComputedStyle(codeEl); + await expect(style.marginTop).toBe("0px"); + await expect(style.marginBottom).toBe("0px"); + // Regression: Shiki can emit a visually-empty trailing line (), which would render + // as a phantom extra line in our line-numbered code blocks. + await expect(container.querySelectorAll(".line-number").length).toBe(1); + }, }; diff --git a/src/browser/styles/globals.css b/src/browser/styles/globals.css index d2f7aaa272..da3076c391 100644 --- a/src/browser/styles/globals.css +++ b/src/browser/styles/globals.css @@ -1414,7 +1414,7 @@ code { } .markdown-content pre { - background: rgba(0, 0, 0, 0.3); + background: var(--color-code-bg); padding: 12px; border-radius: 4px; overflow-x: auto; @@ -1424,6 +1424,7 @@ code { .markdown-content pre code { background: none; padding: 0; + margin: 0; color: var(--color-foreground); } diff --git a/src/browser/utils/highlighting/shiki-shared.test.ts b/src/browser/utils/highlighting/shiki-shared.test.ts new file mode 100644 index 0000000000..53de593d5b --- /dev/null +++ b/src/browser/utils/highlighting/shiki-shared.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; + +import { extractShikiLines } from "./shiki-shared"; + +describe("extractShikiLines", () => { + test("removes trailing visually-empty Shiki line (e.g. )", () => { + const html = `
https://github.com/coder/mux/pull/new/chat-autocomplete-b24r
+
+
`; + + expect(extractShikiLines(html)).toEqual([ + `https://github.com/coder/mux/pull/new/chat-autocomplete-b24r`, + ]); + }); +}); diff --git a/src/browser/utils/highlighting/shiki-shared.ts b/src/browser/utils/highlighting/shiki-shared.ts index 45110e64c6..805a2d48c4 100644 --- a/src/browser/utils/highlighting/shiki-shared.ts +++ b/src/browser/utils/highlighting/shiki-shared.ts @@ -24,6 +24,19 @@ export function mapToShikiLang(detectedLang: string): string { * Extract line contents from Shiki HTML output * Shiki wraps code in
...
with ... per line */ +function isVisuallyEmptyShikiLine(lineHtml: string): boolean { + // Shiki represents an empty line as something like: + // + // which is visually empty but non-empty as a string. + // + // We treat these as empty so callers don't render a phantom blank line. + const textOnly = lineHtml + .replace(/<[^>]*>/g, "") + .replace(/ /g, "") + .trim(); + return textOnly === ""; +} + export function extractShikiLines(html: string): string[] { const codeMatch = /]*>(.*?)<\/code>/s.exec(html); if (!codeMatch) return []; @@ -35,10 +48,11 @@ export function extractShikiLines(html: string): string[] { const contentStart = start + ''.length; const end = chunk.lastIndexOf(""); - return end > contentStart ? chunk.substring(contentStart, end) : ""; + const lineHtml = end > contentStart ? chunk.substring(contentStart, end) : ""; + return isVisuallyEmptyShikiLine(lineHtml) ? "" : lineHtml; }); - // Remove trailing empty lines (Shiki often adds one) + // Remove trailing empty lines (Shiki often adds one). while (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); }