1. Change StickToBottom resize mode from "smooth" to "instant" to prevent scroll position bouncing when new streaming content arrives faster than the smooth animation completes. 2. Use SDK's preset append mode for system prompts instead of replacing Claude Code's default prompt. This preserves Claude Code's built-in awareness of skills (.claude/commands/), working directory context, and other default behaviors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
168 lines
4.3 KiB
TypeScript
168 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import type { ComponentProps } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
|
import { useCallback } from "react";
|
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
|
|
|
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
|
|
|
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|
<StickToBottom
|
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
|
initial="smooth"
|
|
resize="instant"
|
|
role="log"
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type ConversationContentProps = ComponentProps<
|
|
typeof StickToBottom.Content
|
|
>;
|
|
|
|
export const ConversationContent = ({
|
|
className,
|
|
...props
|
|
}: ConversationContentProps) => (
|
|
<StickToBottom.Content
|
|
className={cn("flex flex-col gap-8 p-4", className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
|
title?: string;
|
|
description?: string;
|
|
icon?: React.ReactNode;
|
|
};
|
|
|
|
export const ConversationEmptyState = ({
|
|
className,
|
|
title = "No messages yet",
|
|
description = "Start a conversation to see messages here",
|
|
icon,
|
|
children,
|
|
...props
|
|
}: ConversationEmptyStateProps) => (
|
|
<div
|
|
className={cn(
|
|
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children ?? (
|
|
<>
|
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
|
<div className="space-y-1">
|
|
<h3 className="font-medium text-sm">{title}</h3>
|
|
{description && (
|
|
<p className="text-muted-foreground text-sm">{description}</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
|
|
|
export const ConversationScrollButton = ({
|
|
className,
|
|
...props
|
|
}: ConversationScrollButtonProps) => {
|
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
|
|
const handleScrollToBottom = useCallback(() => {
|
|
scrollToBottom();
|
|
}, [scrollToBottom]);
|
|
|
|
return (
|
|
!isAtBottom && (
|
|
<Button
|
|
className={cn(
|
|
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
|
className
|
|
)}
|
|
onClick={handleScrollToBottom}
|
|
size="icon"
|
|
type="button"
|
|
variant="outline"
|
|
{...props}
|
|
>
|
|
<ArrowDownIcon className="size-4" />
|
|
</Button>
|
|
)
|
|
);
|
|
};
|
|
|
|
export interface ConversationMessage {
|
|
role: "user" | "assistant" | "system" | "data" | "tool";
|
|
content: string;
|
|
}
|
|
|
|
export type ConversationDownloadProps = Omit<
|
|
ComponentProps<typeof Button>,
|
|
"onClick"
|
|
> & {
|
|
messages: ConversationMessage[];
|
|
filename?: string;
|
|
formatMessage?: (message: ConversationMessage, index: number) => string;
|
|
};
|
|
|
|
const defaultFormatMessage = (message: ConversationMessage): string => {
|
|
const roleLabel =
|
|
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
|
return `**${roleLabel}:** ${message.content}`;
|
|
};
|
|
|
|
export const messagesToMarkdown = (
|
|
messages: ConversationMessage[],
|
|
formatMessage: (
|
|
message: ConversationMessage,
|
|
index: number
|
|
) => string = defaultFormatMessage
|
|
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
|
|
|
export const ConversationDownload = ({
|
|
messages,
|
|
filename = "conversation.md",
|
|
formatMessage = defaultFormatMessage,
|
|
className,
|
|
children,
|
|
...props
|
|
}: ConversationDownloadProps) => {
|
|
const handleDownload = useCallback(() => {
|
|
const markdown = messagesToMarkdown(messages, formatMessage);
|
|
const blob = new Blob([markdown], { type: "text/markdown" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.append(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(url);
|
|
}, [messages, filename, formatMessage]);
|
|
|
|
return (
|
|
<Button
|
|
className={cn(
|
|
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
|
className
|
|
)}
|
|
onClick={handleDownload}
|
|
size="icon"
|
|
type="button"
|
|
variant="outline"
|
|
{...props}
|
|
>
|
|
{children ?? <DownloadIcon className="size-4" />}
|
|
</Button>
|
|
);
|
|
};
|