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

View file

@ -73,3 +73,27 @@ export function useTheme() {
return { theme, resolvedTheme, setTheme, toggleTheme } 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
}