From 9e1377da998d52a0a6fbdcf395fcc222955fdc64 Mon Sep 17 00:00:00 2001 From: sneakr Date: Mon, 20 Apr 2026 21:16:32 +0200 Subject: [PATCH] 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 --- .../components/assistant-ui/code-plugin.ts | 66 +++++++++++++++++++ .../components/assistant-ui/code-themes.ts | 30 +++++++++ .../components/assistant-ui/markdown-text.tsx | 8 ++- .../src/components/assistant-ui/reasoning.tsx | 44 +------------ .../src/components/assistant-ui/thread.tsx | 2 +- .../components/assistant-ui/tool-fallback.tsx | 4 +- .../components/assistant-ui/tool-group.tsx | 4 +- studio/frontend/src/hooks/index.ts | 1 + .../src/hooks/use-collapse-scroll-lock.ts | 63 ++++++++++++++++++ studio/frontend/src/index.css | 25 +++++-- 10 files changed, 193 insertions(+), 54 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/code-plugin.ts create mode 100644 studio/frontend/src/components/assistant-ui/code-themes.ts create mode 100644 studio/frontend/src/hooks/use-collapse-scroll-lock.ts diff --git a/studio/frontend/src/components/assistant-ui/code-plugin.ts b/studio/frontend/src/components/assistant-ui/code-plugin.ts new file mode 100644 index 0000000000..5df7ac4f95 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/code-plugin.ts @@ -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 = { + 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, + ), + }; +} diff --git a/studio/frontend/src/components/assistant-ui/code-themes.ts b/studio/frontend/src/components/assistant-ui/code-themes.ts new file mode 100644 index 0000000000..2557b45ef0 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/code-themes.ts @@ -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", +}; diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index c7974db365..6f40dd1b93 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -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} diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index e4306df4b9..3b09a504e9 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -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(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 | 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; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index ffd449fc08..125438cdb6 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -785,7 +785,7 @@ const UserMessage: FC = () => {
-
+
diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx index 82a5b17e04..e407163045 100644 --- a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -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(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; diff --git a/studio/frontend/src/components/assistant-ui/tool-group.tsx b/studio/frontend/src/components/assistant-ui/tool-group.tsx index bf7a6a9a25..e91da4cee2 100644 --- a/studio/frontend/src/components/assistant-ui/tool-group.tsx +++ b/studio/frontend/src/components/assistant-ui/tool-group.tsx @@ -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(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; diff --git a/studio/frontend/src/hooks/index.ts b/studio/frontend/src/hooks/index.ts index 2f64d3249c..f889dc7445 100644 --- a/studio/frontend/src/hooks/index.ts +++ b/studio/frontend/src/hooks/index.ts @@ -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"; diff --git a/studio/frontend/src/hooks/use-collapse-scroll-lock.ts b/studio/frontend/src/hooks/use-collapse-scroll-lock.ts new file mode 100644 index 0000000000..fb7baa062c --- /dev/null +++ b/studio/frontend/src/hooks/use-collapse-scroll-lock.ts @@ -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, + 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 | 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]); +} diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index ff939c08d4..3943c7f882 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -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. */