🤝 feat: View Artifacts in Shared Conversations (#10477)

* feat: Integrate logger for MessageIcon component

* feat: Enhance artifact sharing functionality with updated path checks and read-only state management

* feat: Refactor Thinking and Reasoning components for improved structure and styling

* feat: Enhance artifact sharing with context value management and responsive layout

* feat: Enhance ShareView with theme and language management features

* feat: Improve ThinkingButton accessibility and styling for better user interaction

* feat: Introduce isArtifactRoute utility for route validation in Artifact components

* feat: Add latest message text extraction in SharedView for improved message display

* feat: Update locale handling in SharedView for dynamic date formatting

* feat: Refactor ArtifactsContext and SharedView for improved context handling and styling adjustments

* feat: Enhance artifact panel size management with local storage integration

* chore: imports

* refactor: move ShareArtifactsContainer out of ShareView

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-11-13 22:59:46 +01:00 committed by GitHub
parent cabc8afeac
commit c2505d2bc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 443 additions and 73 deletions

View file

@ -3,7 +3,7 @@ import type { TMessage } from 'librechat-data-provider';
import { useChatContext } from './ChatContext'; import { useChatContext } from './ChatContext';
import { getLatestText } from '~/utils'; import { getLatestText } from '~/utils';
interface ArtifactsContextValue { export interface ArtifactsContextValue {
isSubmitting: boolean; isSubmitting: boolean;
latestMessageId: string | null; latestMessageId: string | null;
latestMessageText: string; latestMessageText: string;
@ -12,10 +12,15 @@ interface ArtifactsContextValue {
const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined); const ArtifactsContext = createContext<ArtifactsContextValue | undefined>(undefined);
export function ArtifactsProvider({ children }: { children: React.ReactNode }) { interface ArtifactsProviderProps {
children: React.ReactNode;
value?: Partial<ArtifactsContextValue>;
}
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
const { isSubmitting, latestMessage, conversation } = useChatContext(); const { isSubmitting, latestMessage, conversation } = useChatContext();
const latestMessageText = useMemo(() => { const chatLatestMessageText = useMemo(() => {
return getLatestText({ return getLatestText({
messageId: latestMessage?.messageId ?? null, messageId: latestMessage?.messageId ?? null,
text: latestMessage?.text ?? null, text: latestMessage?.text ?? null,
@ -23,15 +28,20 @@ export function ArtifactsProvider({ children }: { children: React.ReactNode }) {
} as TMessage); } as TMessage);
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]); }, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
/** Context value only created when relevant values change */ const defaultContextValue = useMemo<ArtifactsContextValue>(
const contextValue = useMemo<ArtifactsContextValue>(
() => ({ () => ({
isSubmitting, isSubmitting,
latestMessageText, latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null, latestMessageId: latestMessage?.messageId ?? null,
conversationId: conversation?.conversationId ?? null, conversationId: conversation?.conversationId ?? null,
}), }),
[isSubmitting, latestMessage?.messageId, latestMessageText, conversation?.conversationId], [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
);
/** Context value only created when relevant values change */
const contextValue = useMemo<ArtifactsContextValue>(
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
[defaultContextValue, value],
); );
return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>; return <ArtifactsContext.Provider value={contextValue}>{children}</ArtifactsContext.Provider>;

View file

@ -6,8 +6,8 @@ import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified'; import type { Pluggable } from 'unified';
import type { Artifact } from '~/common'; import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers'; import { useMessageContext, useArtifactContext } from '~/Providers';
import { logger, extractContent, isArtifactRoute } from '~/utils';
import { artifactsState } from '~/store/artifacts'; import { artifactsState } from '~/store/artifacts';
import { logger, extractContent } from '~/utils';
import ArtifactButton from './ArtifactButton'; import ArtifactButton from './ArtifactButton';
export const artifactPlugin: Pluggable = () => { export const artifactPlugin: Pluggable = () => {
@ -88,7 +88,7 @@ export function Artifact({
lastUpdateTime: now, lastUpdateTime: now,
}; };
if (!location.pathname.includes('/c/')) { if (!isArtifactRoute(location.pathname)) {
return setArtifact(currentArtifact); return setArtifact(currentArtifact);
} }

View file

@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common'; import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview'; import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { cn, getFileType, logger } from '~/utils'; import { cn, getFileType, logger, isArtifactRoute } from '~/utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -37,7 +37,7 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
return; return;
} }
if (!location.pathname.includes('/c/')) { if (!isArtifactRoute(location.pathname)) {
return; return;
} }
@ -57,8 +57,6 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
<div className="group relative my-4 rounded-xl text-sm text-text-primary"> <div className="group relative my-4 rounded-xl text-sm text-text-primary">
{(() => { {(() => {
const handleClick = () => { const handleClick = () => {
if (!location.pathname.includes('/c/')) return;
if (isSelected) { if (isSelected) {
resetCurrentArtifactId(); resetCurrentArtifactId();
setVisible(false); setVisible(false);

View file

@ -157,6 +157,7 @@ export const ArtifactCodeEditor = function ({
artifact, artifact,
editorRef, editorRef,
sharedProps, sharedProps,
readOnly: externalReadOnly,
}: { }: {
fileKey: string; fileKey: string;
artifact: Artifact; artifact: Artifact;
@ -164,6 +165,7 @@ export const ArtifactCodeEditor = function ({
template: SandpackProviderProps['template']; template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>; sharedProps: Partial<SandpackProviderProps>;
editorRef: React.MutableRefObject<CodeEditorRef>; editorRef: React.MutableRefObject<CodeEditorRef>;
readOnly?: boolean;
}) { }) {
const { data: config } = useGetStartupConfig(); const { data: config } = useGetStartupConfig();
const { isSubmitting } = useArtifactsContext(); const { isSubmitting } = useArtifactsContext();
@ -177,10 +179,10 @@ export const ArtifactCodeEditor = function ({
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
}; };
}, [config, template, fileKey]); }, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false); const [readOnly, setReadOnly] = useState(externalReadOnly ?? isSubmitting ?? false);
useEffect(() => { useEffect(() => {
setReadOnly(isSubmitting ?? false); setReadOnly(externalReadOnly ?? isSubmitting ?? false);
}, [isSubmitting]); }, [isSubmitting, externalReadOnly]);
if (Object.keys(files).length === 0) { if (Object.keys(files).length === 0) {
return null; return null;

View file

@ -15,10 +15,12 @@ export default function ArtifactTabs({
artifact, artifact,
editorRef, editorRef,
previewRef, previewRef,
isSharedConvo,
}: { }: {
artifact: Artifact; artifact: Artifact;
editorRef: React.MutableRefObject<CodeEditorRef>; editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>; previewRef: React.MutableRefObject<SandpackPreviewRef>;
isSharedConvo?: boolean;
}) { }) {
const { isSubmitting } = useArtifactsContext(); const { isSubmitting } = useArtifactsContext();
const { currentCode, setCurrentCode } = useCodeState(); const { currentCode, setCurrentCode } = useCodeState();
@ -54,6 +56,7 @@ export default function ArtifactTabs({
artifact={artifact} artifact={artifact}
editorRef={editorRef} editorRef={editorRef}
sharedProps={sharedProps} sharedProps={sharedProps}
readOnly={isSharedConvo}
/> />
</Tabs.Content> </Tabs.Content>

View file

@ -4,10 +4,10 @@ import { Code, Play, RefreshCw, X } from 'lucide-react';
import { useSetRecoilState, useResetRecoilState } from 'recoil'; import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client'; import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts'; import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact'; import DownloadArtifact from './DownloadArtifact';
import ArtifactVersion from './ArtifactVersion'; import ArtifactVersion from './ArtifactVersion';
import { useMutationState } from '~/Providers/EditorContext';
import ArtifactTabs from './ArtifactTabs'; import ArtifactTabs from './ArtifactTabs';
import { CopyCodeButton } from './Code'; import { CopyCodeButton } from './Code';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
@ -20,6 +20,7 @@ const MAX_BACKDROP_OPACITY = 0.3;
export default function Artifacts() { export default function Artifacts() {
const localize = useLocalize(); const localize = useLocalize();
const { isMutating } = useMutationState(); const { isMutating } = useMutationState();
const { isSharedConvo } = useShareContext();
const isMobile = useMediaQuery('(max-width: 868px)'); const isMobile = useMediaQuery('(max-width: 868px)');
const editorRef = useRef<CodeEditorRef>(); const editorRef = useRef<CodeEditorRef>();
const previewRef = useRef<SandpackPreviewRef>(); const previewRef = useRef<SandpackPreviewRef>();
@ -294,6 +295,7 @@ export default function Artifacts() {
artifact={currentArtifact} artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>} editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>} previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
isSharedConvo={isSharedConvo}
/> />
</div> </div>

View file

@ -68,25 +68,27 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
return ( return (
<div className="group/reasoning"> <div className="group/reasoning">
<div className="sticky top-0 z-10 mb-2 bg-surface-secondary pb-2 pt-2"> <div className="group/thinking-container">
<ThinkingButton <div className="sticky top-0 z-10 mb-2 pb-2 pt-2">
isExpanded={isExpanded} <ThinkingButton
onClick={handleClick} isExpanded={isExpanded}
label={label} onClick={handleClick}
content={reasoningText} label={label}
/> content={reasoningText}
</div> />
<div </div>
className={cn( <div
'grid transition-all duration-300 ease-out', className={cn(
nextType !== ContentTypes.THINK && isExpanded && 'mb-4', 'grid transition-all duration-300 ease-out',
)} nextType !== ContentTypes.THINK && isExpanded && 'mb-4',
style={{ )}
gridTemplateRows: isExpanded ? '1fr' : '0fr', style={{
}} gridTemplateRows: isExpanded ? '1fr' : '0fr',
> }}
<div className="overflow-hidden"> >
<ThinkingContent>{reasoningText}</ThinkingContent> <div className="overflow-hidden">
<ThinkingContent>{reasoningText}</ThinkingContent>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -35,11 +35,13 @@ export const ThinkingButton = memo(
onClick, onClick,
label, label,
content, content,
showCopyButton = true,
}: { }: {
isExpanded: boolean; isExpanded: boolean;
onClick: (e: MouseEvent<HTMLButtonElement>) => void; onClick: (e: MouseEvent<HTMLButtonElement>) => void;
label: string; label: string;
content?: string; content?: string;
showCopyButton?: boolean;
}) => { }) => {
const localize = useLocalize(); const localize = useLocalize();
const fontSize = useAtomValue(fontSizeAtom); const fontSize = useAtomValue(fontSizeAtom);
@ -59,7 +61,7 @@ export const ThinkingButton = memo(
); );
return ( return (
<div className="flex w-full items-center justify-between gap-2"> <div className="group/thinking flex w-full items-center justify-between gap-2">
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
@ -79,7 +81,7 @@ export const ThinkingButton = memo(
</span> </span>
{label} {label}
</button> </button>
{content && ( {content && showCopyButton && (
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
@ -90,8 +92,11 @@ export const ThinkingButton = memo(
} }
className={cn( className={cn(
'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200', 'rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
isExpanded
? 'opacity-0 group-focus-within/thinking-container:opacity-100 group-hover/thinking-container:opacity-100'
: 'opacity-0',
'hover:bg-surface-hover hover:text-text-primary', 'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white', 'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
)} )}
> >
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />} {isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
@ -142,7 +147,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
} }
return ( return (
<> <div className="group/thinking-container">
<div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2"> <div className="sticky top-0 z-10 mb-4 bg-surface-primary pb-2 pt-2">
<ThinkingButton <ThinkingButton
isExpanded={isExpanded} isExpanded={isExpanded}
@ -161,7 +166,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN
<ThinkingContent>{children}</ThinkingContent> <ThinkingContent>{children}</ThinkingContent>
</div> </div>
</div> </div>
</> </div>
); );
}); });

View file

@ -18,16 +18,16 @@ export default function MinimalHoverButtons({ message, searchResults }: THoverBu
}); });
return ( return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start"> <div className="visible mt-1 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button <button
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible" className="ml-0 flex items-center gap-1.5 rounded-lg p-1.5 text-xs text-text-secondary-alt transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
onClick={() => copyToClipboard(setIsCopied)} onClick={() => copyToClipboard(setIsCopied)}
type="button" type="button"
title={ title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard') isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
} }
> >
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />} {isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
</button> </button>
</div> </div>
); );

View file

@ -68,9 +68,11 @@ export const ThemeSelector = ({
export const LangSelector = ({ export const LangSelector = ({
langcode, langcode,
onChange, onChange,
portal = true,
}: { }: {
langcode: string; langcode: string;
onChange: (value: string) => void; onChange: (value: string) => void;
portal?: boolean;
}) => { }) => {
const localize = useLocalize(); const localize = useLocalize();
@ -124,10 +126,11 @@ export const LangSelector = ({
<Dropdown <Dropdown
value={langcode} value={langcode}
onChange={onChange} onChange={onChange}
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
options={languageOptions} options={languageOptions}
className="z-50" className="z-50"
aria-labelledby={labelId} aria-labelledby={labelId}
portal={portal}
/> />
</div> </div>
); );

View file

@ -4,7 +4,7 @@ import type { TMessage, Assistant, Agent } from 'librechat-data-provider';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon'; import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getIconEndpoint } from '~/utils'; import { getIconEndpoint, logger } from '~/utils';
export default function MessageIcon( export default function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & { props: Pick<TMessageProps, 'message' | 'conversation'> & {
@ -41,7 +41,7 @@ export default function MessageIcon(
} }
return result; return result;
}, [assistant, agent, assistantAvatar, agentAvatar]); }, [assistant, agent, assistantAvatar, agentAvatar]);
console.log('MessageIcon', { logger.log('MessageIcon', {
endpoint, endpoint,
iconURL, iconURL,
assistantName, assistantName,

View file

@ -0,0 +1,176 @@
import { useState, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
useMediaQuery,
ResizablePanel,
ResizableHandleAlt,
ResizablePanelGroup,
} from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { ArtifactsContextValue } from '~/Providers';
import { ArtifactsProvider, EditorProvider } from '~/Providers';
import Artifacts from '~/components/Artifacts/Artifacts';
import { getLatestText } from '~/utils';
import store from '~/store';
const DEFAULT_ARTIFACT_PANEL_SIZE = 40;
const SHARE_ARTIFACT_PANEL_STORAGE_KEY = 'share:artifacts-panel-size';
const SHARE_ARTIFACT_PANEL_DEFAULT_KEY = 'share:artifacts-panel-size-default';
/**
* Gets the initial artifact panel size from localStorage or returns default
*/
const getInitialArtifactPanelSize = () => {
if (typeof window === 'undefined') {
return DEFAULT_ARTIFACT_PANEL_SIZE;
}
const defaultSizeString = String(DEFAULT_ARTIFACT_PANEL_SIZE);
const storedDefault = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY);
if (storedDefault !== defaultSizeString) {
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_DEFAULT_KEY, defaultSizeString);
window.localStorage.removeItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
return DEFAULT_ARTIFACT_PANEL_SIZE;
}
const stored = window.localStorage.getItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY);
const parsed = Number(stored);
return Number.isFinite(parsed) ? parsed : DEFAULT_ARTIFACT_PANEL_SIZE;
};
interface ShareArtifactsContainerProps {
messages: TMessage[];
conversationId: string;
mainContent: React.ReactNode;
}
/**
* Container component that manages artifact visibility and layout for shared conversations
*/
export function ShareArtifactsContainer({
messages,
conversationId,
mainContent,
}: ShareArtifactsContainerProps) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
const isSmallScreen = useMediaQuery('(max-width: 1023px)');
const [artifactPanelSize, setArtifactPanelSize] = useState(getInitialArtifactPanelSize);
const artifactsContextValue = useMemo<ArtifactsContextValue | null>(() => {
const latestMessage =
Array.isArray(messages) && messages.length > 0 ? messages[messages.length - 1] : null;
if (!latestMessage) {
return null;
}
const latestMessageText = getLatestText(latestMessage);
return {
isSubmitting: false,
latestMessageId: latestMessage.messageId ?? null,
latestMessageText,
conversationId: conversationId ?? null,
};
}, [messages, conversationId]);
const shouldRenderArtifacts =
artifactsVisibility === true &&
artifactsContextValue != null &&
Object.keys(artifacts ?? {}).length > 0;
const normalizedArtifactSize = Math.min(60, Math.max(20, artifactPanelSize));
/**
* Handles artifact panel resize and persists size to localStorage
*/
const handleLayoutChange = (sizes: number[]) => {
if (sizes.length < 2) {
return;
}
const newSize = sizes[1];
if (!Number.isFinite(newSize)) {
return;
}
setArtifactPanelSize(newSize);
if (typeof window !== 'undefined') {
window.localStorage.setItem(SHARE_ARTIFACT_PANEL_STORAGE_KEY, newSize.toString());
}
};
if (!shouldRenderArtifacts || !artifactsContextValue) {
return <>{mainContent}</>;
}
if (isSmallScreen) {
return (
<>
{mainContent}
<ShareArtifactsOverlay contextValue={artifactsContextValue} />
</>
);
}
return (
<ResizablePanelGroup
direction="horizontal"
className="flex h-full w-full"
onLayout={handleLayoutChange}
>
<ResizablePanel
defaultSize={100 - normalizedArtifactSize}
minSize={35}
order={1}
id="share-content"
>
{mainContent}
</ResizablePanel>
<ResizableHandleAlt withHandle className="bg-border-medium text-text-primary" />
<ResizablePanel
defaultSize={normalizedArtifactSize}
minSize={20}
maxSize={60}
order={2}
id="share-artifacts"
>
<ShareArtifactsPanel contextValue={artifactsContextValue} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
interface ShareArtifactsPanelProps {
contextValue: ArtifactsContextValue;
}
/**
* Panel that renders the artifacts UI within a resizable container
*/
function ShareArtifactsPanel({ contextValue }: ShareArtifactsPanelProps) {
return (
<ArtifactsProvider value={contextValue}>
<EditorProvider>
<div className="flex h-full w-full border-l border-border-light bg-surface-primary shadow-2xl">
<Artifacts />
</div>
</EditorProvider>
</ArtifactsProvider>
);
}
/**
* Mobile overlay that displays artifacts in a fixed position
*/
function ShareArtifactsOverlay({ contextValue }: ShareArtifactsPanelProps) {
return (
<div
className="fixed inset-y-0 right-0 z-40 flex w-full max-w-full sm:max-w-[420px]"
role="complementary"
aria-label="Artifacts panel"
>
<ShareArtifactsPanel contextValue={contextValue} />
</div>
);
}

View file

@ -1,22 +1,42 @@
import { memo } from 'react'; import { memo, useState, useCallback, useContext } from 'react';
import { Spinner } from '@librechat/client'; import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { buildTree } from 'librechat-data-provider'; import { buildTree } from 'librechat-data-provider';
import { CalendarDays, Settings } from 'lucide-react';
import { useGetSharedMessages } from 'librechat-data-provider/react-query'; import { useGetSharedMessages } from 'librechat-data-provider/react-query';
import {
Spinner,
Button,
OGDialog,
ThemeContext,
OGDialogTitle,
useMediaQuery,
OGDialogHeader,
OGDialogContent,
OGDialogTrigger,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks'; import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers'; import { ShareContext } from '~/Providers';
import MessagesView from './MessagesView'; import MessagesView from './MessagesView';
import Footer from '../Chat/Footer'; import Footer from '../Chat/Footer';
import { cn } from '~/utils';
import store from '~/store';
function SharedView() { function SharedView() {
const localize = useLocalize(); const localize = useLocalize();
const { data: config } = useGetStartupConfig(); const { data: config } = useGetStartupConfig();
const { theme, setTheme } = useContext(ThemeContext);
const { shareId } = useParams(); const { shareId } = useParams();
const { data, isLoading } = useGetSharedMessages(shareId ?? ''); const { data, isLoading } = useGetSharedMessages(shareId ?? '');
const dataTree = data && buildTree({ messages: data.messages }); const dataTree = data && buildTree({ messages: data.messages });
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null); const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
const [langcode, setLangcode] = useRecoilState(store.lang);
// configure document title // configure document title
let docTitle = ''; let docTitle = '';
if (config?.appTitle != null && data?.title != null) { if (config?.appTitle != null && data?.title != null) {
@ -27,6 +47,48 @@ function SharedView() {
useDocumentTitle(docTitle); useDocumentTitle(docTitle);
const locale =
langcode ||
(typeof navigator !== 'undefined'
? navigator.language || navigator.languages?.[0] || 'en-US'
: 'en-US');
const formattedDate =
data?.createdAt != null
? new Date(data.createdAt).toLocaleDateString(locale, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
: null;
const handleThemeChange = useCallback(
(value: string) => {
setTheme(value);
},
[setTheme],
);
const handleLangChange = useCallback(
(value: string) => {
let userLang = value;
if (value === 'auto') {
userLang =
(typeof navigator !== 'undefined'
? navigator.language || navigator.languages?.[0]
: null) ?? 'en-US';
}
requestAnimationFrame(() => {
document.documentElement.lang = userLang;
});
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},
[setLangcode],
);
let content: JSX.Element; let content: JSX.Element;
if (isLoading) { if (isLoading) {
content = ( content = (
@ -37,17 +99,15 @@ function SharedView() {
} else if (data && messagesTree && messagesTree.length !== 0) { } else if (data && messagesTree && messagesTree.length !== 0) {
content = ( content = (
<> <>
<div className="final-completion group mx-auto flex min-w-[40rem] flex-col gap-3 pb-6 pt-4 md:max-w-[47rem] md:px-5 lg:px-1 xl:max-w-[55rem] xl:px-5"> <ShareHeader
<h1 className="text-4xl font-bold">{data.title}</h1> title={data.title}
<div className="border-b border-border-medium pb-6 text-base text-text-secondary"> formattedDate={formattedDate}
{new Date(data.createdAt).toLocaleDateString('en-US', { theme={theme}
month: 'long', langcode={langcode}
day: 'numeric', onThemeChange={handleThemeChange}
year: 'numeric', onLangChange={handleLangChange}
})} settingsLabel={localize('com_nav_settings')}
</div> />
</div>
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} /> <MessagesView messagesTree={messagesTree} conversationId={data.conversationId} />
</> </>
); );
@ -59,23 +119,124 @@ function SharedView() {
); );
} }
const footer = (
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Footer className="relative mx-auto mt-4 flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-2 text-center text-xs text-text-secondary" />
</div>
);
const mainContent = (
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
<div className="flex h-full flex-col text-text-primary" role="presentation">
{content}
{footer}
</div>
</div>
);
const artifactsContainer =
data && data.messages ? (
<ShareArtifactsContainer
messages={data.messages}
conversationId={data.conversationId}
mainContent={mainContent}
/>
) : (
mainContent
);
return ( return (
<ShareContext.Provider value={{ isSharedConvo: true }}> <ShareContext.Provider value={{ isSharedConvo: true }}>
<main <div className="relative flex min-h-screen w-full dark:bg-surface-secondary">
className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary" <main className="relative flex w-full grow overflow-hidden dark:bg-surface-secondary">
style={{ paddingBottom: '50px' }} {artifactsContainer}
> </main>
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary"> </div>
<div className="flex h-full flex-col text-text-primary" role="presentation">
{content}
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
<Footer className="fixed bottom-0 left-0 right-0 z-50 flex items-center justify-center gap-2 bg-gradient-to-t from-surface-secondary to-transparent px-2 pb-2 pt-8 text-xs text-text-secondary md:px-[60px]" />
</div>
</div>
</div>
</main>
</ShareContext.Provider> </ShareContext.Provider>
); );
} }
interface ShareHeaderProps {
title?: string;
formattedDate: string | null;
theme: string;
langcode: string;
settingsLabel: string;
onThemeChange: (value: string) => void;
onLangChange: (value: string) => void;
}
function ShareHeader({
title,
formattedDate,
theme,
langcode,
settingsLabel,
onThemeChange,
onLangChange,
}: ShareHeaderProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 767px)');
const handleDialogOutside = useCallback((event: Event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('[data-dialog-ignore="true"]')) {
event.preventDefault();
}
}, []);
return (
<section className="mx-auto w-full px-3 pb-4 pt-6 md:px-5">
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-4 rounded-3xl border border-border-light px-6 py-5 shadow-xl backdrop-blur">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="space-y-2">
<h1 className="text-4xl font-semibold text-text-primary">{title}</h1>
{formattedDate && (
<div className="flex items-center gap-2 text-sm text-text-secondary">
<CalendarDays className="size-4" aria-hidden="true" />
<span>{formattedDate}</span>
</div>
)}
</div>
<OGDialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<OGDialogTrigger asChild>
<Button
size={isMobile ? 'icon' : 'default'}
type="button"
variant="outline"
aria-label={settingsLabel}
className={cn(
'rounded-full border-border-medium text-sm text-text-primary transition-colors',
isMobile
? 'absolute bottom-4 right-4 justify-center p-0 shadow-lg'
: 'gap-2 self-start px-4 py-2',
)}
>
<Settings className="size-4" aria-hidden="true" />
<span className="hidden md:inline">{settingsLabel}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent
className="w-11/12 max-w-lg"
showCloseButton={true}
onPointerDownOutside={handleDialogOutside}
onInteractOutside={handleDialogOutside}
>
<OGDialogHeader className="text-left">
<OGDialogTitle>{settingsLabel}</OGDialogTitle>
</OGDialogHeader>
<div className="flex flex-col gap-4 pt-2 text-sm">
<ThemeSelector theme={theme} onChange={onThemeChange} />
<div className="bg-border-medium/60 h-px w-full" />
<LangSelector langcode={langcode} onChange={onLangChange} portal={false} />
</div>
</OGDialogContent>
</OGDialog>
</div>
</div>
</section>
);
}
export default memo(SharedView); export default memo(SharedView);

View file

@ -8,6 +8,7 @@ export * from './forms';
export * from './agents'; export * from './agents';
export * from './drafts'; export * from './drafts';
export * from './convos'; export * from './convos';
export * from './routes';
export * from './presets'; export * from './presets';
export * from './prompts'; export * from './prompts';
export * from './textarea'; export * from './textarea';

View file

@ -0,0 +1,7 @@
import { matchPath } from 'react-router-dom';
const matchesRouteStart = (pathname: string, pattern: string) =>
matchPath({ path: pattern, end: false }, pathname) != null;
export const isArtifactRoute = (pathname: string) =>
matchesRouteStart(pathname, '/c/*') || matchesRouteStart(pathname, '/share/*');