fix: theme-aware syntax highlighting in chat code blocks (#1479)

Inline chat code blocks hardcoded the github-dark Shiki theme, so in light
mode dark-theme token colors rendered on a light bubble with poor contrast.
Resolve the Shiki theme from the active light/dark mode and cache tokens per
theme. Adds a reactive useResolvedTheme hook so blocks update live on toggle.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-10 09:58:20 -07:00 • committed by GitHub
parent 8f596a421d
commit 9475afdf2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 33 additions and 4 deletions

View file

@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { getSingletonHighlighter, type ThemedToken } from "shiki";
import { useResolvedTheme } from "@/lib/theme";
interface CodeBlockProps {
text: string;
language?: string;
}
const SHIKI_THEME = { light: "github-light", dark: "github-dark" } as const;
const TOKEN_CACHE = new Map<string, ThemedToken[][]>();
function normalizeLanguage(language?: string): string {
@ -44,6 +47,8 @@ function languageLabel(language: string): string {
export function CodeBlock({ text, language }: CodeBlockProps) {
const [tokens, setTokens] = useState<ThemedToken[][] | null>(null);
const [copied, setCopied] = useState(false);
const resolvedTheme = useResolvedTheme();
const shikiTheme = SHIKI_THEME[resolvedTheme];
const normalizedLanguage = useMemo(() => normalizeLanguage(language), [language]);
const displayLanguage = useMemo(() => languageLabel(normalizedLanguage), [normalizedLanguage]);
@ -53,7 +58,7 @@ export function CodeBlock({ text, language }: CodeBlockProps) {
if (normalizedLanguage === "text") return;
const cacheKey = `${normalizedLanguage}::${text}`;
const cacheKey = `${shikiTheme}::${normalizedLanguage}::${text}`;
const cached = TOKEN_CACHE.get(cacheKey);
if (cached) {
setTokens(cached);
@ -61,7 +66,7 @@ export function CodeBlock({ text, language }: CodeBlockProps) {
}
getSingletonHighlighter({
themes: ["github-dark"],
themes: [shikiTheme],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
langs: [normalizedLanguage as any],
})
@ -70,7 +75,7 @@ export function CodeBlock({ text, language }: CodeBlockProps) {
const result = highlighter.codeToTokens(text, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
lang: normalizedLanguage as any,
theme: "github-dark",
theme: shikiTheme,
});
if (TOKEN_CACHE.size >= 500) TOKEN_CACHE.clear();
TOKEN_CACHE.set(cacheKey, result.tokens);
@ -86,7 +91,7 @@ export function CodeBlock({ text, language }: CodeBlockProps) {
return () => {
cancelled = true;
};
}, [text, normalizedLanguage]);
}, [text, normalizedLanguage, shikiTheme]);
const handleCopy = async () => {
try {

View file

@ -73,3 +73,27 @@ export function useTheme() {
return { theme, resolvedTheme, setTheme, toggleTheme }
}
function readDomResolvedTheme(): ResolvedTheme {
if (typeof document === "undefined") return "light"
return document.documentElement.classList.contains("dark") ? "dark" : "light"
}
/** Reactive resolved theme that tracks the root `.dark` class set by `useTheme`. */
export function useResolvedTheme(): ResolvedTheme {
const [resolved, setResolved] = useState<ResolvedTheme>(readDomResolvedTheme)
useEffect(() => {
setResolved(readDomResolvedTheme())
const observer = new MutationObserver(() =>
setResolved(readDomResolvedTheme())
)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
return resolved
}