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:
parent
380dd94790
commit
9e1377da99
10 changed files with 193 additions and 54 deletions
66
studio/frontend/src/components/assistant-ui/code-plugin.ts
Normal file
66
studio/frontend/src/components/assistant-ui/code-plugin.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
30
studio/frontend/src/components/assistant-ui/code-themes.ts
Normal file
30
studio/frontend/src/components/assistant-ui/code-themes.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
63
studio/frontend/src/hooks/use-collapse-scroll-lock.ts
Normal file
63
studio/frontend/src/hooks/use-collapse-scroll-lock.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue