Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

1036 lines
30 KiB
TypeScript

"use client";
import "@assistant-ui/react-markdown/styles/dot.css";
import {
ArrowDownIcon,
ArrowUpIcon,
BotIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
LoaderIcon,
PaperclipIcon,
PencilIcon,
RefreshCwIcon,
SquareIcon,
ThumbsDownIcon,
ThumbsUpIcon,
UserIcon,
Volume2Icon,
} from "lucide-react";
import {
ActionBarPrimitive,
AuiIf,
BranchPickerPrimitive,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useMessagePartText,
} from "@assistant-ui/react";
import {
type FC,
createContext,
useContext,
useMemo,
memo,
useState,
useEffect,
} from "react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import {
ReasoningRoot,
ReasoningTrigger,
ReasoningContent,
ReasoningText,
} from "@/components/assistant-ui/reasoning";
import {
Source,
SourceIcon,
SourceTitle,
} from "@/components/assistant-ui/sources";
import {
type CodeHeaderProps,
MarkdownTextPrimitive,
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
type SyntaxHighlighterProps,
} from "@assistant-ui/react-markdown";
import remarkGfm from "remark-gfm";
import ShikiHighlighter from "react-shiki";
import {
SHIKI_THEME_MAP,
DEFAULT_COLORS,
type BuilderConfig,
type CodeHighlightTheme,
type ThemeColor,
} from "./types";
import {
BORDER_RADIUS_CLASS,
MESSAGE_SPACING_CLASS,
isLightColor,
} from "@/lib/builder-utils";
interface BuilderPreviewContextValue {
config: BuilderConfig;
isDark: boolean;
accentColor: string;
}
const BuilderPreviewContext = createContext<BuilderPreviewContextValue | null>(
null,
);
function useBuilderPreviewContext() {
const context = useContext(BuilderPreviewContext);
if (!context) {
throw new Error(
"useBuilderPreviewContext must be used within BuilderPreviewProvider",
);
}
return context;
}
const UserMessageWrapper: FC = () => {
const { config } = useBuilderPreviewContext();
return <UserMessage config={config} />;
};
const AssistantMessageWrapper: FC = () => {
const { config } = useBuilderPreviewContext();
return <AssistantMessage config={config} />;
};
const PlainText: FC = () => {
const { text } = useMessagePartText();
return <p className="whitespace-pre-wrap">{text}</p>;
};
const MarkdownTextWrapper: FC = () => {
const { config } = useBuilderPreviewContext();
return (
<ConfigurableMarkdownText
codeHighlightTheme={config.components.codeHighlightTheme}
/>
);
};
interface BuilderPreviewProps {
config: BuilderConfig;
}
// Hook to detect page theme from document.documentElement.classList
function usePageTheme() {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Return false during SSR/hydration to avoid mismatch, then update on client
if (!mounted) return false;
return resolvedTheme === "dark";
}
export function BuilderPreview({ config }: BuilderPreviewProps) {
const { components, styles } = config;
// Always follow page theme so user can preview both light and dark variants
const isDark = usePageTheme();
// Helper to get color value based on current theme
const getColor = (
color: ThemeColor | undefined,
fallback: ThemeColor,
): string => {
const c = color ?? fallback;
return isDark ? c.dark : c.light;
};
const { colors } = styles;
const accentColor = getColor(colors.accent, DEFAULT_COLORS.accent);
// Define CSS variables with theme-aware values
const cssVars = {
"--aui-thread-max-width": styles.maxWidth,
"--aui-accent-color": accentColor,
"--aui-background": getColor(colors.background, DEFAULT_COLORS.background),
"--aui-foreground": getColor(colors.foreground, DEFAULT_COLORS.foreground),
"--aui-muted": getColor(colors.muted, DEFAULT_COLORS.muted),
"--aui-muted-foreground": getColor(
colors.mutedForeground,
DEFAULT_COLORS.mutedForeground,
),
"--aui-border": getColor(colors.border, DEFAULT_COLORS.border),
"--aui-user-message-background": getColor(
colors.userMessage,
DEFAULT_COLORS.userMessage,
),
"--aui-assistant-message-background": colors.assistantMessage
? getColor(colors.assistantMessage, DEFAULT_COLORS.background)
: undefined,
"--aui-composer-background": getColor(
colors.composer,
DEFAULT_COLORS.composer,
),
"--aui-user-avatar-background": getColor(
colors.userAvatar,
DEFAULT_COLORS.userAvatar,
),
"--aui-assistant-avatar-background": getColor(
colors.assistantAvatar,
DEFAULT_COLORS.assistantAvatar,
),
"--aui-suggestion-background": getColor(
colors.suggestion,
DEFAULT_COLORS.suggestion,
),
"--aui-suggestion-border": getColor(
colors.suggestionBorder,
DEFAULT_COLORS.suggestionBorder,
),
fontFamily: styles.fontFamily,
} as React.CSSProperties;
return (
<BuilderPreviewContext.Provider value={{ config, isDark, accentColor }}>
<div
className={cn("h-full w-full", isDark ? "dark" : "light")}
style={cssVars}
>
{config.customCSS && (
<style>{`@scope (.aui-root) { ${config.customCSS} }`}</style>
)}
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full flex-col"
style={{
backgroundColor: "var(--aui-background)",
color: "var(--aui-foreground)",
fontSize: styles.fontSize,
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4"
>
{components.threadWelcome && (
<AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome config={config} />
</AuiIf>
)}
{!components.threadWelcome && (
<AuiIf condition={(s) => s.thread.isEmpty}>
<div className="grow" />
</AuiIf>
)}
<ThreadPrimitive.Messages>
{({ message }) => {
if (message.composer.isEditing) return <EditComposer />;
if (message.role === "user") return <UserMessageWrapper />;
return <AssistantMessageWrapper />;
}}
</ThreadPrimitive.Messages>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--aui-thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl pb-4 md:pb-6"
style={{ backgroundColor: "var(--aui-background)" }}
>
{components.scrollToBottom && <ThreadScrollToBottom />}
<Composer config={config} />
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
</div>
</BuilderPreviewContext.Provider>
);
}
interface ThreadWelcomeProps {
config: BuilderConfig;
}
const ThreadWelcome: FC<ThreadWelcomeProps> = ({ config }) => {
const { components, styles } = config;
return (
<div className="aui-thread-welcome-root mx-auto my-auto flex w-full max-w-(--aui-thread-max-width) grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
<h1
className={cn(
"aui-thread-welcome-message-inner text-2xl font-semibold",
styles.animations &&
"fade-in slide-in-from-bottom-1 animate-in duration-200",
)}
>
Hello there!
</h1>
<p
className={cn(
"aui-thread-welcome-message-inner text-xl",
styles.animations &&
"fade-in slide-in-from-bottom-1 animate-in delay-75 duration-200",
)}
style={{ color: "var(--aui-muted-foreground)" }}
>
How can I help you today?
</p>
</div>
</div>
{components.suggestions && <ThreadSuggestions config={config} />}
</div>
);
};
const SUGGESTIONS = [
{
title: "What's the weather",
label: "in San Francisco?",
prompt: "What's the weather in San Francisco?",
},
{
title: "Explain React hooks",
label: "like useState and useEffect",
prompt: "Explain React hooks like useState and useEffect",
},
] as const;
interface ThreadSuggestionsProps {
config: BuilderConfig;
}
const ThreadSuggestions: FC<ThreadSuggestionsProps> = ({ config }) => {
const { styles } = config;
return (
<div className="aui-thread-welcome-suggestions grid w-full gap-2 pb-4 @md:grid-cols-2">
{SUGGESTIONS.map((suggestion, index) => (
<div
key={suggestion.prompt}
className={cn(
"aui-thread-welcome-suggestion-display nth-[n+3]:hidden @md:nth-[n+3]:block",
styles.animations &&
"fade-in slide-in-from-bottom-2 animate-in fill-mode-both duration-200",
)}
style={
styles.animations
? { animationDelay: `${100 + index * 50}ms` }
: undefined
}
>
<ThreadPrimitive.Suggestion prompt={suggestion.prompt} send asChild>
<Button
variant="ghost"
className="aui-thread-welcome-suggestion h-auto w-full flex-wrap items-start justify-start gap-1 rounded-2xl px-4 py-3 text-left text-sm transition-colors @md:flex-col"
style={{
backgroundColor: "var(--aui-suggestion-background)",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "var(--aui-suggestion-border)",
}}
aria-label={suggestion.prompt}
>
<span className="aui-thread-welcome-suggestion-text-1 font-medium">
{suggestion.title}
</span>
<span
className="aui-thread-welcome-suggestion-text-2"
style={{ color: "var(--aui-muted-foreground)" }}
>
{suggestion.label}
</span>
</Button>
</ThreadPrimitive.Suggestion>
</div>
))}
</div>
);
};
interface ComposerProps {
config: BuilderConfig;
}
const Composer: FC<ComposerProps> = ({ config }) => {
const { styles } = config;
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone
className={cn(
"aui-composer-attachment-dropzone flex w-full flex-col px-1 pt-2 transition-colors outline-none",
"data-[dragging=true]:bg-accent/50 data-[dragging=true]:border-dashed",
BORDER_RADIUS_CLASS[styles.borderRadius],
)}
style={{
backgroundColor: "var(--aui-composer-background)",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "var(--aui-border)",
}}
>
<ComposerPrimitive.Input
placeholder="Send a message..."
className="aui-composer-input mb-1 max-h-32 min-h-14 w-full resize-none bg-transparent px-4 pt-2 pb-3 text-sm outline-none placeholder:text-(--aui-muted-foreground) focus-visible:ring-0"
rows={1}
autoFocus
aria-label="Message input"
/>
<ComposerAction config={config} />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};
interface ComposerActionProps {
config: BuilderConfig;
}
const ComposerAction: FC<ComposerActionProps> = ({ config }) => {
const { accentColor } = useBuilderPreviewContext();
const { components } = config;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
{components.attachments ? (
<ComposerPrimitive.AddAttachment asChild>
<TooltipIconButton
tooltip="Add attachment"
variant="ghost"
style={{ color: "var(--aui-muted-foreground)" }}
>
<PaperclipIcon className="size-5" />
</TooltipIconButton>
</ComposerPrimitive.AddAttachment>
) : (
<div />
)}
<AuiIf condition={(s) => !s.thread.isRunning}>
<ComposerPrimitive.Send asChild>
<TooltipIconButton
tooltip="Send message"
side="bottom"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isLightColor(accentColor) ? "text-black" : "text-white",
)}
style={{ backgroundColor: "var(--aui-accent-color)" }}
aria-label="Send message"
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AuiIf>
<AuiIf condition={(s) => s.thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className={cn(
"aui-composer-cancel size-8 rounded-full",
isLightColor(accentColor) ? "text-black" : "text-white",
)}
style={{ backgroundColor: "var(--aui-accent-color)" }}
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AuiIf>
</div>
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom absolute -top-12 z-10 self-center rounded-full p-4 disabled:invisible"
style={{
backgroundColor: "var(--aui-background)",
borderColor: "var(--aui-border)",
}}
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
interface UserMessageProps {
config: BuilderConfig;
}
const UserMessage: FC<UserMessageProps> = ({ config }) => {
const { components, styles } = config;
const isLeftAligned = styles.userMessagePosition === "left";
const messageSpacingClass = MESSAGE_SPACING_CLASS[styles.messageSpacing];
// For left-aligned, use flex layout like before
// For right-aligned (default), use grid layout like thread.tsx
if (isLeftAligned) {
return (
<MessagePrimitive.Root
className={cn(
"aui-user-message-root mx-auto flex w-full max-w-(--aui-thread-max-width) gap-3 px-2",
messageSpacingClass,
styles.animations &&
"fade-in slide-in-from-bottom-1 animate-in duration-150",
)}
data-role="user"
>
{components.avatar && (
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: "var(--aui-user-avatar-background)" }}
>
<UserIcon className="size-4" />
</div>
)}
<div className="relative max-w-[80%] min-w-0">
<div
className={cn(
"aui-user-message-content peer px-4 py-2.5 wrap-break-word empty:hidden",
BORDER_RADIUS_CLASS[styles.borderRadius],
)}
style={{ backgroundColor: "var(--aui-user-message-background)" }}
>
<MessagePrimitive.Parts />
</div>
{components.editMessage && (
<div className="aui-user-action-bar-wrapper absolute top-1/2 right-0 translate-x-full -translate-y-1/2 pl-2 peer-empty:hidden">
<UserActionBar />
</div>
)}
</div>
{components.branchPicker && (
<BranchPicker className="aui-user-branch-picker -mr-1 self-end" />
)}
</MessagePrimitive.Root>
);
}
// Right-aligned (default) - use grid layout like thread.tsx
return (
<MessagePrimitive.Root
className={cn(
"aui-user-message-root mx-auto grid w-full max-w-(--aui-thread-max-width) auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2",
messageSpacingClass,
"[&:where(>*)]:col-start-2",
styles.animations &&
"fade-in slide-in-from-bottom-1 animate-in duration-150",
)}
data-role="user"
>
{components.avatar && (
<div className="col-start-2 flex justify-end">
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: "var(--aui-user-avatar-background)" }}
>
<UserIcon className="size-4" />
</div>
</div>
)}
<div className="aui-user-message-content-wrapper relative col-start-2 min-w-0">
<div
className={cn(
"aui-user-message-content peer px-4 py-2.5 wrap-break-word empty:hidden",
BORDER_RADIUS_CLASS[styles.borderRadius],
)}
style={{ backgroundColor: "var(--aui-user-message-background)" }}
>
<MessagePrimitive.Parts />
</div>
{components.editMessage && (
<div className="aui-user-action-bar-wrapper absolute top-1/2 left-0 -translate-x-full -translate-y-1/2 pr-2 peer-empty:hidden">
<UserActionBar />
</div>
)}
</div>
{components.branchPicker && (
<BranchPicker className="aui-user-branch-picker col-span-full col-start-1 row-start-3 -mr-1 justify-end" />
)}
</MessagePrimitive.Root>
);
};
const UserActionBar: FC = () => {
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-user-action-bar-root flex flex-col items-end"
>
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit p-4">
<PencilIcon />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
</ActionBarPrimitive.Root>
);
};
interface AssistantMessageProps {
config: BuilderConfig;
}
const AssistantMessage: FC<AssistantMessageProps> = ({ config }) => {
const { components, styles } = config;
const messageSpacingClass = MESSAGE_SPACING_CLASS[styles.messageSpacing];
const TextComponent = components.markdown ? MarkdownTextWrapper : PlainText;
return (
<MessagePrimitive.Root
className={cn(
"aui-assistant-message-root relative mx-auto w-full max-w-(--aui-thread-max-width) px-2",
messageSpacingClass,
styles.animations &&
"fade-in slide-in-from-bottom-1 animate-in duration-150",
)}
data-role="assistant"
style={
components.typingIndicator !== "dot"
? ({ "--aui-content": "none" } as React.CSSProperties)
: undefined
}
>
<div className="flex gap-3">
{components.avatar && (
<div
className="flex size-8 shrink-0 items-center justify-center rounded-full"
style={{
backgroundColor: "var(--aui-assistant-avatar-background)",
}}
>
<BotIcon className="size-4" />
</div>
)}
<div className="min-w-0 flex-1 space-y-2">
{components.reasoning && (
<ReasoningRoot variant="muted" className="mb-0">
<ReasoningTrigger />
<ReasoningContent>
<ReasoningText>
<p>
Let me analyze this step by step. First, I&apos;ll consider
the key points of your question...
</p>
</ReasoningText>
</ReasoningContent>
</ReasoningRoot>
)}
<div
className={cn(
"aui-assistant-message-content leading-relaxed wrap-break-word",
styles.colors.assistantMessage && "rounded-2xl px-4 py-3",
)}
style={
styles.colors.assistantMessage
? { backgroundColor: "var(--aui-assistant-message-background)" }
: undefined
}
>
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <TextComponent />;
return null;
}}
</MessagePrimitive.Parts>
{components.loadingIndicator !== "none" && (
<AuiIf
condition={({ thread, message }) =>
thread.isRunning && message.content.length === 0
}
>
<div
className="flex items-center gap-2"
style={{ color: "var(--aui-muted-foreground)" }}
>
<LoaderIcon className="size-4 animate-spin" />
{components.loadingIndicator === "text" && (
<span className="text-sm">{components.loadingText}</span>
)}
</div>
</AuiIf>
)}
</div>
{components.sources && (
<div className="mt-2 flex flex-wrap gap-1.5">
<Source href="https://react.dev">
<SourceIcon url="https://react.dev" />
<SourceTitle>React Documentation</SourceTitle>
</Source>
<Source href="https://nextjs.org">
<SourceIcon url="https://nextjs.org" />
<SourceTitle>Next.js</SourceTitle>
</Source>
</div>
)}
<div className="aui-assistant-message-footer flex min-h-6 items-center">
{components.branchPicker && <BranchPicker />}
<AssistantActionBar config={config} />
</div>
{components.followUpSuggestions && (
<AuiIf condition={(s) => !s.thread.isRunning}>
<FollowUpSuggestions />
</AuiIf>
)}
</div>
</div>
</MessagePrimitive.Root>
);
};
const FollowUpSuggestions: FC = () => {
return (
<div className="flex flex-wrap gap-2">
<ThreadPrimitive.Suggestion
prompt="Tell me more"
className="rounded-full px-3 py-1 text-sm"
style={{
backgroundColor: "var(--aui-suggestion-background)",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "var(--aui-suggestion-border)",
}}
>
Tell me more
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion
prompt="Can you explain differently?"
className="rounded-full px-3 py-1 text-sm"
style={{
backgroundColor: "var(--aui-suggestion-background)",
borderWidth: "1px",
borderStyle: "solid",
borderColor: "var(--aui-suggestion-border)",
}}
>
Explain differently
</ThreadPrimitive.Suggestion>
</div>
);
};
interface AssistantActionBarProps {
config: BuilderConfig;
}
const AssistantActionBar: FC<AssistantActionBarProps> = ({ config }) => {
const { components } = config;
const { actionBar } = components;
if (
!actionBar.copy &&
!actionBar.reload &&
!actionBar.speak &&
!actionBar.feedback
) {
return null;
}
return (
<ActionBarPrimitive.Root
hideWhenRunning
autohide="not-last"
className="aui-assistant-action-bar-root -ml-1 flex gap-1"
style={{ color: "var(--aui-muted-foreground)" }}
>
{actionBar.copy && (
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AuiIf condition={(s) => s.message.isCopied}>
<CheckIcon />
</AuiIf>
<AuiIf condition={(s) => !s.message.isCopied}>
<CopyIcon />
</AuiIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
)}
{actionBar.reload && (
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
)}
{actionBar.speak && (
<ActionBarPrimitive.Speak asChild>
<TooltipIconButton tooltip="Read aloud">
<Volume2Icon />
</TooltipIconButton>
</ActionBarPrimitive.Speak>
)}
{actionBar.feedback && (
<>
<TooltipIconButton tooltip="Good response">
<ThumbsUpIcon />
</TooltipIconButton>
<TooltipIconButton tooltip="Bad response">
<ThumbsDownIcon />
</TooltipIconButton>
</>
)}
</ActionBarPrimitive.Root>
);
};
interface BranchPickerProps {
className?: string;
}
const BranchPicker: FC<BranchPickerProps> = ({ className }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root mr-2 -ml-2 inline-flex items-center text-xs",
className,
)}
style={{ color: "var(--aui-muted-foreground)" }}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};
const EditComposer: FC = () => {
return (
<MessagePrimitive.Root className="aui-edit-composer-wrapper mx-auto flex w-full max-w-(--aui-thread-max-width) flex-col px-2 py-3">
<ComposerPrimitive.Root className="aui-edit-composer-root bg-muted ml-auto flex w-full max-w-[85%] flex-col rounded-2xl">
<ComposerPrimitive.Input
className="aui-edit-composer-input text-foreground min-h-14 w-full resize-none bg-transparent p-4 text-sm outline-none"
autoFocus
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild>
<Button variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<ComposerPrimitive.Send asChild>
<Button size="sm">Update</Button>
</ComposerPrimitive.Send>
</div>
</ComposerPrimitive.Root>
</MessagePrimitive.Root>
);
};
const MarkdownCodeHeader: FC<CodeHeaderProps> = ({ language }) => (
<div className="border-border/50 bg-muted/50 mt-2.5 flex items-center justify-between rounded-t-lg border border-b-0 px-3 py-1.5 text-xs">
<span className="text-muted-foreground font-medium lowercase">
{language}
</span>
</div>
);
const MarkdownH1: FC<React.ComponentProps<"h1">> = ({
className,
...props
}) => (
<h1
className={cn(
"mb-2 scroll-m-20 text-base font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
);
const MarkdownH2: FC<React.ComponentProps<"h2">> = ({
className,
...props
}) => (
<h2
className={cn(
"mt-3 mb-1.5 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
);
const MarkdownH3: FC<React.ComponentProps<"h3">> = ({
className,
...props
}) => (
<h3
className={cn(
"mt-2.5 mb-1 scroll-m-20 text-sm font-semibold first:mt-0 last:mb-0",
className,
)}
{...props}
/>
);
const MarkdownP: FC<React.ComponentProps<"p">> = ({ className, ...props }) => (
<p
className={cn("my-2.5 leading-normal first:mt-0 last:mb-0", className)}
{...props}
/>
);
const MarkdownUl: FC<React.ComponentProps<"ul">> = ({
className,
...props
}) => (
<ul
className={cn(
"marker:text-muted-foreground my-2 ml-4 list-disc [&>li]:mt-1",
className,
)}
{...props}
/>
);
const MarkdownOl: FC<React.ComponentProps<"ol">> = ({
className,
...props
}) => (
<ol
className={cn(
"marker:text-muted-foreground my-2 ml-4 list-decimal [&>li]:mt-1",
className,
)}
{...props}
/>
);
const MarkdownPre: FC<React.ComponentProps<"pre">> = ({
className,
...props
}) => (
<pre
className={cn(
"border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-lg border border-t-0 p-3 text-xs leading-relaxed",
className,
)}
{...props}
/>
);
const MarkdownCode: FC<React.ComponentProps<"code">> = ({
className,
...props
}) => {
const isCodeBlock = useIsMarkdownCodeBlock();
return (
<code
className={cn(
!isCodeBlock &&
"border-border/50 bg-muted/50 rounded-md border px-1.5 py-0.5 font-mono text-[0.85em]",
className,
)}
{...props}
/>
);
};
const MarkdownLi: FC<React.ComponentProps<"li">> = ({
className,
...props
}) => <li className={cn("leading-normal", className)} {...props} />;
const baseMarkdownComponents = {
h1: MarkdownH1,
h2: MarkdownH2,
h3: MarkdownH3,
p: MarkdownP,
ul: MarkdownUl,
ol: MarkdownOl,
li: MarkdownLi,
pre: MarkdownPre,
code: MarkdownCode,
CodeHeader: MarkdownCodeHeader,
};
const createSyntaxHighlighter = (
theme: Exclude<CodeHighlightTheme, "none">,
): FC<SyntaxHighlighterProps> => {
const SyntaxHighlighter: FC<SyntaxHighlighterProps> = ({
code,
language,
}) => (
<ShikiHighlighter
language={language ?? "text"}
theme={SHIKI_THEME_MAP[theme]}
addDefaultStyles={false}
showLanguage={false}
as="div"
className="not-fumadocs-codeblock border-border/50 bg-muted/30 overflow-x-auto rounded-t-none rounded-b-lg border border-t-0 p-3 text-xs leading-relaxed [&_.line:last-child:empty]:hidden"
>
{code}
</ShikiHighlighter>
);
return SyntaxHighlighter;
};
interface ConfigurableMarkdownTextProps {
codeHighlightTheme: CodeHighlightTheme;
}
const ConfigurableMarkdownText: FC<ConfigurableMarkdownTextProps> = memo(
({ codeHighlightTheme }) => {
const components = useMemo(() => {
if (codeHighlightTheme === "none") {
return memoizeMarkdownComponents(baseMarkdownComponents);
}
return memoizeMarkdownComponents({
...baseMarkdownComponents,
SyntaxHighlighter: createSyntaxHighlighter(codeHighlightTheme),
});
}, [codeHighlightTheme]);
return (
<MarkdownTextPrimitive
remarkPlugins={[remarkGfm]}
className="aui-md"
components={components}
/>
);
},
);
ConfigurableMarkdownText.displayName = "ConfigurableMarkdownText";