From 9475afdf2ecf4c9163d986caf6ce07d51d416de1 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 10 Jun 2026 09:58:20 -0700 Subject: [PATCH] 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] --- ui/src/components/agents/ported/CodeBlock.tsx | 13 ++++++---- ui/src/lib/theme.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/ui/src/components/agents/ported/CodeBlock.tsx b/ui/src/components/agents/ported/CodeBlock.tsx index ba46cbef..f223266e 100644 --- a/ui/src/components/agents/ported/CodeBlock.tsx +++ b/ui/src/components/agents/ported/CodeBlock.tsx @@ -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(); function normalizeLanguage(language?: string): string { @@ -44,6 +47,8 @@ function languageLabel(language: string): string { export function CodeBlock({ text, language }: CodeBlockProps) { const [tokens, setTokens] = useState(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 { diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts index 2cbca249..24670259 100644 --- a/ui/src/lib/theme.ts +++ b/ui/src/lib/theme.ts @@ -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(readDomResolvedTheme) + + useEffect(() => { + setResolved(readDomResolvedTheme()) + const observer = new MutationObserver(() => + setResolved(readDomResolvedTheme()) + ) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + return () => observer.disconnect() + }, []) + + return resolved +}