Code block redesign, font, and chat bubble adjustments

- Redesigned code block colors and theme
- Changed code block font to Fira Code
- Fixed scrollbar disappearing when expanding/collapsing tool calls in chats
- Adjusted chat bubble background color
This commit is contained in:
sneakr 2026-04-20 21:16:32 +02:00
commit 9e1377da99
10 changed files with 193 additions and 54 deletions

View file

@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import {
createCodePlugin as createShikiCodePlugin,
type CodeHighlighterPlugin,
type CodePluginOptions,
type HighlightOptions,
type HighlightResult,
} from "@streamdown/code";
import type { BundledLanguage } from "shiki";
// Fence tags LLMs/users commonly write that shiki doesn't expose as aliases.
// Keys are lower-cased input; values are canonical shiki language ids.
const LANGUAGE_ALIAS_OVERRIDES: Record<string, BundledLanguage> = {
objectivec: "objective-c",
"obj-c": "objective-c",
objectivecpp: "objective-cpp",
"objective-cplusplus": "objective-cpp",
objcpp: "objective-cpp",
"c++": "cpp",
cplusplus: "cpp",
"c#": "csharp",
cs: "csharp",
"f#": "fsharp",
"c-sharp": "csharp",
"f-sharp": "fsharp",
golang: "go",
rs: "rust",
rb: "ruby",
py: "python",
sh: "shellscript",
bash: "shellscript",
zsh: "shellscript",
shell: "shellscript",
yml: "yaml",
ts: "typescript",
js: "javascript",
kt: "kotlin",
rsx: "rust",
"vue-html": "vue",
};
const normalizeLanguage = (language: string): BundledLanguage => {
const key = language.trim().toLowerCase();
const override = LANGUAGE_ALIAS_OVERRIDES[key];
return (override ?? (key as BundledLanguage));
};
export function createCodePlugin(
options: CodePluginOptions = {},
): CodeHighlighterPlugin {
const inner = createShikiCodePlugin(options);
return {
...inner,
supportsLanguage: (language) => inner.supportsLanguage(normalizeLanguage(language)),
highlight: (
opts: HighlightOptions,
callback?: (result: HighlightResult) => void,
) =>
inner.highlight(
{ ...opts, language: normalizeLanguage(opts.language) },
callback,
),
};
}

View file

@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import oneDarkPro from "@shikijs/themes/one-dark-pro";
import oneLight from "@shikijs/themes/one-light";
import type { ThemeRegistrationAny } from "shiki";
// Canonical Atom One Dark / One Light themes, shipped by `@shikijs/themes`.
// We only override the background so the code block blends into the app's
// `--code-block` surface instead of painting its own. Every token color and
// scope mapping is left intact — that's what gives consistent multi-language
// highlighting (including Objective-C, Go, Rust, etc.) out of the box.
const withTransparentBg = (theme: ThemeRegistrationAny): ThemeRegistrationAny => ({
...theme,
bg: "transparent",
colors: {
...theme.colors,
"editor.background": "transparent",
},
});
export const unslothLightTheme: ThemeRegistrationAny = {
...withTransparentBg(oneLight),
name: "unsloth-light",
};
export const unslothDarkTheme: ThemeRegistrationAny = {
...withTransparentBg(oneDarkPro),
name: "unsloth-dark",
};

View file

@ -8,7 +8,7 @@ import { preprocessLaTeX } from "@/lib/latex";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { code } from "@streamdown/code";
import { createCodePlugin } from "./code-plugin";
import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
@ -16,8 +16,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Block, type BlockProps, Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
import { AudioPlayer } from "./audio-player";
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({
themes: [unslothLightTheme, unslothDarkTheme],
});
const { withSmoothContextProvider } = INTERNAL;
const STREAMDOWN_COMPONENTS = {
@ -425,7 +429,7 @@ const MarkdownTextImpl = () => {
panZoom: true,
},
}}
shikiTheme={["github-light", "github-dark"]}
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
BlockComponent={StreamdownBlock}
>
{processedText}

View file

@ -11,6 +11,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
type ReasoningGroupComponent,
@ -67,49 +68,8 @@ function ReasoningRoot({
...props
}: ReasoningRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const lockCleanupRef = useRef<(() => void) | null>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
useEffect(() => {
return () => {
lockCleanupRef.current?.();
};
}, []);
const lockScroll = useCallback(() => {
lockCleanupRef.current?.();
const animatedElement = collapsibleRef.current;
if (!animatedElement) return;
let scrollContainer: HTMLElement | null = animatedElement;
while (scrollContainer) {
const { overflowY } = getComputedStyle(scrollContainer);
if (overflowY === "scroll" || overflowY === "auto") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (!scrollContainer) return;
const scrollPosition = scrollContainer.scrollTop;
const resetPosition = () => {
scrollContainer.scrollTop = scrollPosition;
};
scrollContainer.addEventListener("scroll", resetPosition);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
scrollContainer.removeEventListener("scroll", resetPosition);
lockCleanupRef.current = null;
};
timeoutId = setTimeout(cleanup, ANIMATION_DURATION);
lockCleanupRef.current = cleanup;
}, []);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -785,7 +785,7 @@ const UserMessage: FC = () => {
<UserMessageAudio />
<div className="aui-user-message-content-wrapper flex max-w-[80%] min-w-0 flex-col items-end">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f0f0f0] px-4 py-2.5 text-foreground dark:bg-card">
<div className="aui-user-message-content wrap-break-word w-fit rounded-[16px] rounded-tr-[4px] bg-[#f5f5f5] px-4 py-2.5 text-foreground dark:bg-background">
<MessagePrimitive.Parts />
</div>
<div className="mt-1 flex min-h-6">

View file

@ -8,11 +8,11 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
import {
type ToolCallMessagePartComponent,
type ToolCallMessagePartStatus,
useScrollLock,
} from "@assistant-ui/react";
import {
AlertCircleIcon,
@ -52,7 +52,7 @@ function ToolFallbackRoot({
}: ToolFallbackRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -12,12 +12,12 @@ import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { Wrench01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { useScrollLock } from "@assistant-ui/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useCollapseScrollLock } from "@/hooks/use-collapse-scroll-lock";
import { cn } from "@/lib/utils";
const ANIMATION_DURATION = 200;
@ -54,7 +54,7 @@ function ToolGroupRoot({
}: ToolGroupRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const lockScroll = useCollapseScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;

View file

@ -11,3 +11,4 @@ export { useHfDatasetSearch } from "./use-hf-dataset-search";
export { useHfDatasetSplits } from "./use-hf-dataset-splits";
export { useHfTokenValidation } from "./use-hf-token-validation";
export { useInfiniteScroll } from "./use-infinite-scroll";
export { useCollapseScrollLock } from "./use-collapse-scroll-lock";

View file

@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { type RefObject, useCallback, useEffect, useRef } from "react";
/**
* Locks the nearest scrollable ancestor's scrollTop for the duration of a
* collapsible animation so the page doesn't jump when content height changes.
*
* Unlike @assistant-ui/react's `useScrollLock`, this hook does NOT toggle
* `scrollbar-width: none` on the container. Hiding the scrollbar mid-animation
* caused a visible disappear/reappear flicker on tool-call collapsibles.
*/
export function useCollapseScrollLock(
animatedElementRef: RefObject<HTMLElement | null>,
animationDurationMs: number,
): () => void {
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => {
cleanupRef.current?.();
};
}, []);
return useCallback(() => {
cleanupRef.current?.();
const animatedElement = animatedElementRef.current;
if (!animatedElement) return;
let scrollContainer: HTMLElement | null = animatedElement;
while (scrollContainer) {
const { overflowY } = getComputedStyle(scrollContainer);
if (overflowY === "scroll" || overflowY === "auto") {
break;
}
scrollContainer = scrollContainer.parentElement;
}
if (!scrollContainer) return;
const container = scrollContainer;
const scrollPosition = container.scrollTop;
const resetPosition = () => {
container.scrollTop = scrollPosition;
};
container.addEventListener("scroll", resetPosition);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
container.removeEventListener("scroll", resetPosition);
if (cleanupRef.current === cleanup) {
cleanupRef.current = null;
}
};
timeoutId = setTimeout(cleanup, animationDurationMs);
cleanupRef.current = cleanup;
}, [animatedElementRef, animationDurationMs]);
}

View file

@ -39,6 +39,14 @@
font-display: swap;
}
@font-face {
font-family: "Fira Code";
src: url("/fonts/FiraCode-VariableFont_wght.ttf") format("truetype-variations");
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
:root {
/* Animation timing */
--duration-micro: 100ms;
@ -400,8 +408,8 @@
}
[data-streamdown="code-block"] {
gap: 0;
padding: 0.5rem;
gap: 0.25rem;
padding: 0.75rem 1rem;
/* Wide lines must scroll inside the thread column, not widen past the composer (flex min-width:auto). */
max-width: 100%;
min-width: 0;
@ -409,7 +417,14 @@
}
[data-streamdown="code-block-header"] {
padding-left: 0.75rem;
padding-left: 0;
}
.aui-thread-root [data-streamdown="code-block"] code > span::before {
content: none !important;
display: none !important;
margin: 0 !important;
width: 0 !important;
}
/* Chat thread: code slightly smaller by default; step up when the thread column is wide. */
@ -441,11 +456,11 @@
/* Keep monospace for code fences and inline code (not KaTeX). */
.aui-thread-root [data-streamdown="code-block"] pre,
.aui-thread-root [data-streamdown="code-block"] code {
font-family: var(--font-mono), ui-monospace, monospace;
font-family: "Fira Code", ui-monospace, monospace;
}
.aui-thread-root :where(p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6) code {
font-family: var(--font-mono), ui-monospace, monospace;
font-family: "Fira Code", ui-monospace, monospace;
}
/* Align fenced code blocks with the main chat column even when nested in lists. */