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
78 changes: 78 additions & 0 deletions src/browser/stories/App.markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 {
Expand Down Expand Up @@ -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
\`\`\``;

// ═══════════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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 (<span></span>), which would render
// as a phantom extra line in our line-numbered code blocks.
await expect(container.querySelectorAll(".line-number").length).toBe(1);
},
};
3 changes: 2 additions & 1 deletion src/browser/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -1424,6 +1424,7 @@ code {
.markdown-content pre code {
background: none;
padding: 0;
margin: 0;
color: var(--color-foreground);
}

Expand Down
15 changes: 15 additions & 0 deletions src/browser/utils/highlighting/shiki-shared.test.ts
Original file line number Diff line number Diff line change
@@ -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. <span></span>)", () => {
const html = `<pre class="shiki"><code><span class="line"><span style="color:#fff">https://github.com/coder/mux/pull/new/chat-autocomplete-b24r</span></span>
<span class="line"><span style="color:#fff"></span></span>
</code></pre>`;

expect(extractShikiLines(html)).toEqual([
`<span style="color:#fff">https://github.com/coder/mux/pull/new/chat-autocomplete-b24r</span>`,
]);
});
});
18 changes: 16 additions & 2 deletions src/browser/utils/highlighting/shiki-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ export function mapToShikiLang(detectedLang: string): string {
* Extract line contents from Shiki HTML output
* Shiki wraps code in <pre><code>...</code></pre> with <span class="line">...</span> per line
*/
function isVisuallyEmptyShikiLine(lineHtml: string): boolean {
// Shiki represents an empty line as something like:
// <span class="line"><span style="..."></span></span>
// 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(/&nbsp;/g, "")
.trim();
return textOnly === "";
}

export function extractShikiLines(html: string): string[] {
const codeMatch = /<code[^>]*>(.*?)<\/code>/s.exec(html);
if (!codeMatch) return [];
Expand All @@ -35,10 +48,11 @@ export function extractShikiLines(html: string): string[] {
const contentStart = start + '<span class="line">'.length;
const end = chunk.lastIndexOf("</span>");

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();
}
Expand Down