🏎️ refactor: Replace Sandpack Code Editor with Monaco for Artifact Editing (#12109)

* refactor: Code Editor and Auto Scroll Functionality

- Added a useEffect hook in CodeEditor to sync streaming content with Sandpack without remounting the provider, improving performance and user experience.
- Updated useAutoScroll to accept an optional editorRef, allowing for dynamic scroll container selection based on the editor's state.
- Refactored ArtifactTabs to utilize the new editorRef in the useAutoScroll hook, ensuring consistent scrolling behavior during content updates.
- Introduced stableFiles and mergedFiles logic in CodeEditor to optimize file handling and prevent unnecessary updates during streaming content changes.

* refactor: Update CodeEditor to Sync Streaming Content Based on Read-Only State

- Modified the useEffect hook in CodeEditor to conditionally sync streaming content with Sandpack only when in read-only mode, preventing unnecessary updates during user edits.
- Enhanced the dependency array of the useEffect hook to include the readOnly state, ensuring accurate synchronization behavior.

* refactor: Monaco Editor for Artifact Code Editing

* refactor: Clean up ArtifactCodeEditor and ArtifactTabs components

- Removed unused scrollbar styles from mobile.css to streamline the code.
- Refactored ArtifactCodeEditor to improve content synchronization and read-only state handling.
- Enhanced ArtifactTabs by removing unnecessary context usage and optimizing component structure for better readability.

* feat: Add support for new artifact type 'application/vnd.ant.react'

- Introduced handling for 'application/vnd.ant.react' in artifactFilename, artifactTemplate, and dependenciesMap.
- Updated relevant mappings to ensure proper integration of the new artifact type within the application.

* refactor:ArtifactCodeEditor with Monaco Editor Configuration

- Added support for disabling validation in the Monaco Editor to improve the artifact viewer/editor experience.
- Introduced a new type definition for Monaco to enhance type safety.
- Updated the handling of the 'application/vnd.ant.react' artifact type to ensure proper integration with the editor.

* refactor: Clean up ArtifactCodeEditor and mobile.css

- Removed unnecessary whitespace in mobile.css for cleaner code.
- Refactored ArtifactCodeEditor to streamline language mapping and type handling, enhancing readability and maintainability.
- Consolidated language and type mappings into dedicated constants for improved clarity and efficiency.

* feat: Integrate Monaco Editor for Enhanced Code Editing Experience

- Added the Monaco Editor as a dependency to improve the code editing capabilities within the ArtifactCodeEditor component.
- Refactored the handling of TypeScript and JavaScript defaults in the Monaco Editor configuration for better type safety and clarity.
- Streamlined the setup for disabling validation, enhancing the artifact viewer/editor experience.

* fix: Update ArtifactCodeEditor to handle null content checks

- Modified conditional checks in ArtifactCodeEditor to use `art.content != null` instead of `art.content` for improved null safety.
- Ensured consistent handling of artifact content across various useEffect hooks to prevent potential errors when content is null.

* fix: Refine content comparison logic in ArtifactCodeEditor

- Updated the condition for checking if the code is not original by removing the redundant null check for `art.content`, ensuring more concise and clear logic.
- This change enhances the readability of the code and maintains the integrity of content comparison within the editor.

* fix: Simplify code comparison logic in ArtifactCodeEditor

- Removed redundant null check for the `code` variable, ensuring a more straightforward comparison with the current update reference.
- This change improves code clarity and maintains the integrity of the content comparison logic within the editor.
This commit is contained in:
Danny Avila 2026-03-06 15:02:04 -05:00 committed by GitHub
parent a79f7cebd5
commit 771227ecf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 385 additions and 349 deletions

View file

@ -38,6 +38,7 @@
"@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^5.7.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
@ -146,6 +147,7 @@
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^30.2.0",
"monaco-editor": "^0.55.0",
"jest-file-loader": "^1.0.3",
"jest-junit": "^16.0.0",
"postcss": "^8.4.31",

View file

@ -1,206 +1,326 @@
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import debounce from 'lodash/debounce';
import { KeyBinding } from '@codemirror/view';
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
import {
useSandpack,
SandpackCodeEditor,
SandpackProvider as StyledProvider,
} from '@codesandbox/sandpack-react';
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { ArtifactFiles, Artifact } from '~/common';
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
import MonacoEditor from '@monaco-editor/react';
import type { Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
import { useEditArtifact } from '~/data-provider';
const CodeEditor = memo(
({
fileKey,
readOnly,
artifact,
editorRef,
}: {
fileKey: string;
readOnly?: boolean;
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
}) => {
const { sandpack } = useSandpack();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const { setCurrentCode } = useCodeState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const LANG_MAP: Record<string, string> = {
javascript: 'javascript',
typescript: 'typescript',
python: 'python',
css: 'css',
json: 'json',
markdown: 'markdown',
html: 'html',
xml: 'xml',
sql: 'sql',
yaml: 'yaml',
shell: 'shell',
bash: 'shell',
tsx: 'typescript',
jsx: 'javascript',
c: 'c',
cpp: 'cpp',
java: 'java',
go: 'go',
rust: 'rust',
kotlin: 'kotlin',
swift: 'swift',
php: 'php',
ruby: 'ruby',
r: 'r',
lua: 'lua',
scala: 'scala',
perl: 'perl',
};
/**
* Create stable debounced mutation that doesn't depend on changing callbacks
* Use refs to always access the latest values without recreating the debounce
*/
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const TYPE_MAP: Record<string, string> = {
'text/html': 'html',
'application/vnd.code-html': 'html',
'application/vnd.react': 'typescript',
'application/vnd.ant.react': 'typescript',
'text/markdown': 'markdown',
'text/md': 'markdown',
'text/plain': 'plaintext',
'application/vnd.mermaid': 'markdown',
};
useEffect(() => {
artifactRef.current = artifact;
}, [artifact]);
function getMonacoLanguage(type?: string, language?: string): string {
if (language && LANG_MAP[language]) {
return LANG_MAP[language];
}
return TYPE_MAP[type ?? ''] ?? 'plaintext';
}
useEffect(() => {
isMutatingRef.current = isMutating;
}, [isMutating]);
useEffect(() => {
currentUpdateRef.current = currentUpdate;
}, [currentUpdate]);
useEffect(() => {
editArtifactRef.current = editArtifact;
}, [editArtifact]);
useEffect(() => {
setCurrentCodeRef.current = setCurrentCode;
}, [setCurrentCode]);
/**
* Create debounced mutation once - never recreate it
* All values are accessed via refs so they're always current
*/
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly) {
return;
}
if (isMutatingRef.current) {
return;
}
if (artifactRef.current.index == null) {
return;
}
const artifact = artifactRef.current;
const artifactIndex = artifact.index;
const isNotOriginal =
code && artifact.content != null && code.trim() !== artifact.content.trim();
const isNotRepeated =
currentUpdateRef.current == null
? true
: code != null && code.trim() !== currentUpdateRef.current.trim();
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: artifactIndex,
messageId: artifact.messageId ?? '',
original: artifact.content,
updated: code,
});
}
}, 500),
[readOnly],
);
/**
* Listen to Sandpack file changes and trigger debounced mutation
*/
useEffect(() => {
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
if (currentCode) {
debouncedMutation(currentCode);
}
}, [sandpack.files, fileKey, debouncedMutation]);
/**
* Cleanup: cancel pending mutations when component unmounts or artifact changes
*/
useEffect(() => {
return () => {
debouncedMutation.cancel();
};
}, [artifact.id, debouncedMutation]);
return (
<SandpackCodeEditor
ref={editorRef}
showTabs={false}
showRunButton={false}
showLineNumbers={true}
showInlineErrors={true}
readOnly={readOnly === true}
extensions={[autocompletion()]}
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
className="hljs language-javascript bg-black"
/>
);
},
);
export const ArtifactCodeEditor = function ({
files,
fileKey,
template,
export const ArtifactCodeEditor = function ArtifactCodeEditor({
artifact,
editorRef,
sharedProps,
monacoRef,
readOnly: externalReadOnly,
}: {
fileKey: string;
artifact: Artifact;
files: ArtifactFiles;
template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>;
monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
readOnly?: boolean;
}) {
const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext();
const options: typeof sharedOptions = useMemo(() => {
if (!config) {
return sharedOptions;
}
return {
...sharedOptions,
activeFile: '/' + fileKey,
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
};
}, [config, template, fileKey]);
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
const [readOnly, setReadOnly] = useState(initialReadOnly);
useEffect(() => {
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
}, [isSubmitting, externalReadOnly]);
const readOnly = (externalReadOnly ?? false) || isSubmitting;
const { setCurrentCode } = useCodeState();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
if (Object.keys(files).length === 0) {
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const prevContentRef = useRef(artifact.content ?? '');
const prevArtifactId = useRef(artifact.id);
const prevReadOnly = useRef(readOnly);
artifactRef.current = artifact;
isMutatingRef.current = isMutating;
currentUpdateRef.current = currentUpdate;
editArtifactRef.current = editArtifact;
setCurrentCodeRef.current = setCurrentCode;
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
return;
}
const art = artifactRef.current;
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
const isNotRepeated =
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: art.index,
messageId: art.messageId ?? '',
original: art.content,
updated: code,
});
}
}, 500),
[readOnly],
);
useEffect(() => {
return () => debouncedMutation.cancel();
}, [artifact.id, debouncedMutation]);
/**
* Streaming: use model.applyEdits() to append new content.
* Unlike setValue/pushEditOperations, applyEdits preserves existing
* tokens so syntax highlighting doesn't flash during updates.
*/
useEffect(() => {
const ed = monacoRef.current;
if (!ed || !readOnly) {
return;
}
const newContent = artifact.content ?? '';
const prev = prevContentRef.current;
if (newContent === prev) {
return;
}
const model = ed.getModel();
if (!model) {
return;
}
if (newContent.startsWith(prev) && prev.length > 0) {
const appended = newContent.slice(prev.length);
const endPos = model.getPositionAt(model.getValueLength());
model.applyEdits([
{
range: {
startLineNumber: endPos.lineNumber,
startColumn: endPos.column,
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
},
text: appended,
},
]);
} else {
model.setValue(newContent);
}
prevContentRef.current = newContent;
ed.revealLine(model.getLineCount());
}, [artifact.content, readOnly, monacoRef]);
useEffect(() => {
if (artifact.id === prevArtifactId.current) {
return;
}
prevArtifactId.current = artifact.id;
prevContentRef.current = artifact.content ?? '';
const ed = monacoRef.current;
if (ed && artifact.content != null) {
ed.getModel()?.setValue(artifact.content);
}
}, [artifact.id, artifact.content, monacoRef]);
useEffect(() => {
if (prevReadOnly.current && !readOnly && artifact.content != null) {
const ed = monacoRef.current;
if (ed) {
ed.getModel()?.setValue(artifact.content);
prevContentRef.current = artifact.content;
}
}
prevReadOnly.current = readOnly;
}, [readOnly, artifact.content, monacoRef]);
const handleChange = useCallback(
(value: string | undefined) => {
if (value === undefined || readOnly) {
return;
}
prevContentRef.current = value;
setCurrentCode(value);
if (value.length > 0) {
debouncedMutation(value);
}
},
[readOnly, debouncedMutation, setCurrentCode],
);
/**
* Disable all validation this is an artifact viewer/editor, not an IDE.
* Note: these are global Monaco settings that affect all editor instances on the page.
* The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript`
* as `{ deprecated: true }` while the runtime API is fully functional.
*/
const handleBeforeMount = useCallback((monaco: Monaco) => {
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
.typescript as unknown as {
typescriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
javascriptDefaults: {
setDiagnosticsOptions: (o: {
noSemanticValidation: boolean;
noSyntaxValidation: boolean;
}) => void;
setCompilerOptions: (o: {
allowNonTsExtensions: boolean;
allowJs: boolean;
jsx: number;
}) => void;
};
JsxEmit: { React: number };
};
const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true };
const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React };
typescriptDefaults.setDiagnosticsOptions(diagnosticsOff);
javascriptDefaults.setDiagnosticsOptions(diagnosticsOff);
typescriptDefaults.setCompilerOptions(compilerBase);
javascriptDefaults.setCompilerOptions(compilerBase);
}, []);
const handleMount = useCallback(
(ed: editor.IStandaloneCodeEditor) => {
monacoRef.current = ed;
prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? '';
if (readOnly) {
const model = ed.getModel();
if (model) {
ed.revealLine(model.getLineCount());
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[monacoRef],
);
const language = getMonacoLanguage(artifact.type, artifact.language);
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
() => ({
readOnly,
minimap: { enabled: false },
lineNumbers: 'on',
scrollBeyondLastLine: false,
fontSize: 13,
tabSize: 2,
wordWrap: 'on',
automaticLayout: true,
padding: { top: 8 },
renderLineHighlight: readOnly ? 'none' : 'line',
cursorStyle: readOnly ? 'underline-thin' : 'line',
scrollbar: {
vertical: 'visible',
horizontal: 'auto',
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
useShadows: false,
alwaysConsumeMouseWheel: false,
},
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
folding: false,
glyphMargin: false,
colorDecorators: !readOnly,
occurrencesHighlight: readOnly ? 'off' : 'singleFile',
selectionHighlight: !readOnly,
renderValidationDecorations: readOnly ? 'off' : 'editable',
quickSuggestions: !readOnly,
suggestOnTriggerCharacters: !readOnly,
parameterHints: { enabled: !readOnly },
hover: { enabled: !readOnly },
matchBrackets: readOnly ? 'never' : 'always',
}),
[readOnly],
);
if (!artifact.content) {
return null;
}
return (
<StyledProvider
theme="dark"
files={{
...files,
...sharedFiles,
}}
options={options}
{...sharedProps}
template={template}
>
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
</StyledProvider>
<div className="h-full w-full bg-[#1e1e1e]">
<MonacoEditor
height="100%"
language={language}
theme="vs-dark"
defaultValue={artifact.content}
onChange={handleChange}
beforeMount={handleBeforeMount}
onMount={handleMount}
options={editorOptions}
/>
</div>
);
};

View file

@ -1,30 +1,26 @@
import { useRef, useEffect } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
import type { editor } from 'monaco-editor';
import type { Artifact } from '~/common';
import { useCodeState } from '~/Providers/EditorContext';
import { useArtifactsContext } from '~/Providers';
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview';
export default function ArtifactTabs({
artifact,
editorRef,
previewRef,
isSharedConvo,
}: {
artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) {
const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useCodeState();
const { data: startupConfig } = useGetStartupConfig();
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const lastIdRef = useRef<string | null>(null);
useEffect(() => {
@ -34,33 +30,24 @@ export default function ArtifactTabs({
lastIdRef.current = artifact.id;
}, [setCurrentCode, artifact.id]);
const content = artifact.content ?? '';
const contentRef = useRef<HTMLDivElement>(null);
useAutoScroll({ ref: contentRef, content, isSubmitting });
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
return (
<div className="flex h-full w-full flex-col">
<Tabs.Content
ref={contentRef}
value="code"
id="artifacts-code"
className="h-full w-full flex-grow overflow-auto"
tabIndex={-1}
>
<ArtifactCodeEditor
files={files}
fileKey={fileKey}
template={template}
artifact={artifact}
editorRef={editorRef}
sharedProps={sharedProps}
readOnly={isSharedConvo}
/>
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
</Tabs.Content>
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
<Tabs.Content
value="preview"
className="h-full w-full flex-grow overflow-hidden"
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}

View file

@ -3,7 +3,7 @@ import * as Tabs from '@radix-ui/react-tabs';
import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
@ -22,7 +22,6 @@ export default function Artifacts() {
const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
@ -297,7 +296,6 @@ export default function Artifacts() {
<div className="absolute inset-0 flex flex-col">
<ArtifactTabs
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
/>

View file

@ -1,11 +1,8 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import React, { memo, useState } from 'react';
import copy from 'copy-to-clipboard';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import { Button } from '@librechat/client';
import rehypeHighlight from 'rehype-highlight';
import { Copy, CircleCheckBig } from 'lucide-react';
import { handleDoubleClick, langSubset } from '~/utils';
import { handleDoubleClick } from '~/utils';
import { useLocalize } from '~/hooks';
type TCodeProps = {
@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
});
export const CodeMarkdown = memo(
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex],
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: langSubset,
},
],
];
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, []);
useEffect(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled]);
return (
<div ref={scrollRef} className="max-h-full overflow-y-auto">
<ReactMarkdown
/* @ts-ignore */
rehypePlugins={rehypePlugins}
components={
{ code } as {
[key: string]: React.ElementType;
}
}
>
{currentContent}
</ReactMarkdown>
</div>
);
},
);
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);

View file

@ -1,47 +0,0 @@
// hooks/useAutoScroll.ts
import { useEffect, useState } from 'react';
interface UseAutoScrollProps {
ref: React.RefObject<HTMLElement>;
content: string;
isSubmitting: boolean;
}
export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => {
const [userScrolled, setUserScrolled] = useState(false);
useEffect(() => {
const scrollContainer = ref.current;
if (!scrollContainer) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isNearBottom) {
setUserScrolled(true);
} else {
setUserScrolled(false);
}
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [ref]);
useEffect(() => {
const scrollContainer = ref.current;
if (!scrollContainer || !isSubmitting || userScrolled) {
return;
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}, [content, isSubmitting, userScrolled, ref]);
return { userScrolled };
};

View file

@ -349,26 +349,6 @@
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] {
scrollbar-gutter: stable !important;
background-color: rgba(205, 205, 205, 0.66) !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar {
width: 12px !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-thumb {
background-color: rgba(56, 56, 56) !important;
border-radius: 6px !important;
border: 2px solid transparent !important;
background-clip: padding-box !important;
}
div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-track {
background-color: transparent !important;
}
.cm-content:focus {
outline: none !important;
}

View file

@ -7,6 +7,7 @@ import type {
const artifactFilename = {
'application/vnd.react': 'App.tsx',
'application/vnd.ant.react': 'App.tsx',
'text/html': 'index.html',
'application/vnd.code-html': 'index.html',
// mermaid and markdown types are handled separately in useArtifactProps.ts
@ -28,6 +29,7 @@ const artifactTemplate: Record<
> = {
'text/html': 'static',
'application/vnd.react': 'react-ts',
'application/vnd.ant.react': 'react-ts',
'application/vnd.mermaid': 'react-ts',
'application/vnd.code-html': 'static',
'text/markdown': 'react-ts',
@ -119,6 +121,7 @@ const dependenciesMap: Record<
> = {
'application/vnd.mermaid': mermaidDependencies,
'application/vnd.react': standardDependencies,
'application/vnd.ant.react': standardDependencies,
'text/html': standardDependencies,
'application/vnd.code-html': standardDependencies,
'text/markdown': markdownDependencies,