364 lines
9.2 KiB
TypeScript
364 lines
9.2 KiB
TypeScript
import { cn } from "@code/ui/lib/utils";
|
|
import { ArrowUp, GripVertical, LoaderCircle } from "lucide-react";
|
|
import type { ComponentProps, ReactNode } from "react";
|
|
|
|
const MobileViewport = ({ className, ...props }: ComponentProps<"div">) => (
|
|
<div
|
|
data-slot="mobile-viewport"
|
|
className={cn(
|
|
"relative mx-auto h-[100dvh] min-h-0 w-full max-w-[390px] overflow-hidden bg-background text-foreground sm:border-x sm:border-border/70",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
const MobileChatMark = ({ className, ...props }: ComponentProps<"span">) => (
|
|
<span
|
|
aria-hidden="true"
|
|
data-slot="mobile-chat-mark"
|
|
className={cn(
|
|
"inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-[#f1f1ef] text-[#171716]",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<GripVertical className="size-3.5" strokeWidth={2.2} />
|
|
</span>
|
|
);
|
|
|
|
interface MobileChatHeaderProps extends Omit<
|
|
ComponentProps<"header">,
|
|
"children"
|
|
> {
|
|
active?: boolean;
|
|
label?: string;
|
|
statusLabel: string;
|
|
}
|
|
|
|
const MobileChatHeader = ({
|
|
active = false,
|
|
className,
|
|
label = "Zopu",
|
|
statusLabel,
|
|
...props
|
|
}: MobileChatHeaderProps) => (
|
|
<header
|
|
data-slot="mobile-chat-header"
|
|
className={cn(
|
|
"flex h-[78px] shrink-0 items-center border-b border-[#e9e9e7] bg-[#fefefe] px-4",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<div className="flex w-full items-center gap-3">
|
|
<MobileChatMark className="size-7 rounded-[7px]" />
|
|
<h1 className="text-[17px] leading-none font-semibold tracking-[-0.015em] text-[#171716]">
|
|
{label}
|
|
</h1>
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"ml-auto size-1.5 rounded-full bg-[#b9bbb8]",
|
|
active && "bg-[#17c777]"
|
|
)}
|
|
/>
|
|
<span className="sr-only" aria-live="polite">
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
);
|
|
|
|
interface MobileChatMessageProps extends Omit<
|
|
ComponentProps<"article">,
|
|
"role"
|
|
> {
|
|
sender: "assistant" | "user";
|
|
}
|
|
|
|
const MobileChatMessage = ({
|
|
className,
|
|
sender,
|
|
...props
|
|
}: MobileChatMessageProps) => (
|
|
<article
|
|
data-sender={sender}
|
|
data-slot="mobile-chat-message"
|
|
className={cn(
|
|
"flex w-full min-w-0",
|
|
sender === "user" ? "justify-end" : "justify-start",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
interface MobileChatBubbleProps extends Omit<ComponentProps<"div">, "role"> {
|
|
sender: "assistant" | "user";
|
|
}
|
|
|
|
const MobileChatBubble = ({
|
|
className,
|
|
sender,
|
|
...props
|
|
}: MobileChatBubbleProps) => (
|
|
<div
|
|
data-sender={sender}
|
|
data-slot="mobile-chat-bubble"
|
|
className={cn(
|
|
"min-w-0 text-[15px] tracking-[-0.012em] wrap-break-word",
|
|
sender === "user"
|
|
? "max-w-[260px] rounded-[20px] bg-[#0d0d0c] px-[14px] py-[11px] leading-[18px] text-[#fafafa]"
|
|
: "w-full leading-[18px] text-[#232321]",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
|
|
interface MobileChatAssistantLabelProps extends ComponentProps<"div"> {
|
|
label?: string;
|
|
state?: ReactNode;
|
|
}
|
|
|
|
const MobileChatAssistantLabel = ({
|
|
className,
|
|
label = "Zopu",
|
|
state,
|
|
...props
|
|
}: MobileChatAssistantLabelProps) => (
|
|
<div
|
|
data-slot="mobile-chat-assistant-label"
|
|
className={cn(
|
|
"mb-3 flex items-center gap-2 text-[14px] leading-5 text-[#6f706e]",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<MobileChatMark className="size-5 rounded-[5px] [&_svg]:size-3" />
|
|
<span>{label}</span>
|
|
{state}
|
|
</div>
|
|
);
|
|
|
|
interface MobileChatToolCallProps extends ComponentProps<"div"> {
|
|
detail?: ReactNode;
|
|
icon?: ReactNode;
|
|
status: string;
|
|
tone?: "error" | "neutral" | "success";
|
|
toolName: string;
|
|
}
|
|
|
|
const MobileChatToolCall = ({
|
|
children,
|
|
className,
|
|
detail,
|
|
icon,
|
|
status,
|
|
tone = "neutral",
|
|
toolName,
|
|
...props
|
|
}: MobileChatToolCallProps) => {
|
|
let content: ReactNode;
|
|
if (children) {
|
|
content = <div className="mt-2 grid gap-2">{children}</div>;
|
|
} else if (detail) {
|
|
content = (
|
|
<div className="mt-2 flex items-start gap-2">
|
|
<MobileChatMark className="mt-0.5 size-4 rounded-[4px] [&_svg]:size-2.5" />
|
|
<p className="line-clamp-2 min-w-0 text-[11px] leading-[15px] text-[#555653]">
|
|
{detail}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-slot="mobile-chat-tool-call"
|
|
className={cn(
|
|
"mb-3 overflow-hidden rounded-[16px] bg-[#f4f4f2] px-3 py-3 text-[#171716]",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<div className="flex min-h-5 items-center gap-2">
|
|
<span className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-[#e8e8e5] text-[#171716]">
|
|
{icon ?? <GripVertical className="size-3" strokeWidth={2.2} />}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 font-semibold">
|
|
{toolName}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"rounded-full bg-[#e6e7e4] px-2 py-1 text-[9px] leading-none font-medium text-[#454643]",
|
|
tone === "success" && "bg-[#dff5e8] text-[#174b32]",
|
|
tone === "error" && "bg-[#f8dfdc] text-[#7b2821]"
|
|
)}
|
|
>
|
|
{status}
|
|
</span>
|
|
</div>
|
|
{content}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface MobileChatToolResultProps extends ComponentProps<"div"> {
|
|
icon?: ReactNode;
|
|
source?: string;
|
|
title: string;
|
|
}
|
|
|
|
const MobileChatToolResult = ({
|
|
className,
|
|
icon,
|
|
source,
|
|
title,
|
|
...props
|
|
}: MobileChatToolResultProps) => (
|
|
<div
|
|
data-slot="mobile-chat-tool-result"
|
|
className={cn("flex min-w-0 items-start gap-2", className)}
|
|
{...props}
|
|
>
|
|
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-[#e8e8e5] text-[#171716]">
|
|
{icon ?? <GripVertical className="size-2.5" strokeWidth={2.2} />}
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-[11px] leading-[14px] font-medium text-[#292a28]">
|
|
{title}
|
|
</span>
|
|
{source ? (
|
|
<span className="block truncate text-[9px] leading-[12px] text-[#9a9b98]">
|
|
{source}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
);
|
|
|
|
interface MobileChatComposerDockProps extends ComponentProps<"div"> {
|
|
errorMessage?: string;
|
|
statusMessage?: ReactNode;
|
|
}
|
|
|
|
const MobileChatComposerDock = ({
|
|
children,
|
|
className,
|
|
errorMessage,
|
|
statusMessage,
|
|
...props
|
|
}: MobileChatComposerDockProps) => {
|
|
const message = errorMessage ?? statusMessage;
|
|
|
|
return (
|
|
<div
|
|
data-slot="mobile-chat-composer-dock"
|
|
className={cn(
|
|
"shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]",
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{message ? (
|
|
<div
|
|
id={errorMessage ? "composer-error" : undefined}
|
|
className={cn(
|
|
"px-3 pt-2 text-center text-[10px] leading-4 text-[#777875]",
|
|
errorMessage && "text-destructive"
|
|
)}
|
|
>
|
|
{message}
|
|
</div>
|
|
) : null}
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface MobileChatComposerProps extends Omit<
|
|
ComponentProps<"form">,
|
|
"children"
|
|
> {
|
|
busy?: boolean;
|
|
canSend: boolean;
|
|
errorMessage?: string;
|
|
statusMessage?: ReactNode;
|
|
textareaProps: ComponentProps<"textarea">;
|
|
}
|
|
|
|
const MobileChatComposer = ({
|
|
busy = false,
|
|
canSend,
|
|
className,
|
|
errorMessage,
|
|
statusMessage,
|
|
textareaProps,
|
|
...props
|
|
}: MobileChatComposerProps) => {
|
|
const { className: textareaClassName, ...inputProps } = textareaProps;
|
|
|
|
return (
|
|
<MobileChatComposerDock
|
|
errorMessage={errorMessage}
|
|
statusMessage={statusMessage}
|
|
>
|
|
<form
|
|
aria-label="Send a message"
|
|
className={cn("w-full px-3 pt-[11px] pb-3", className)}
|
|
{...props}
|
|
>
|
|
<div className="flex items-end gap-2.5">
|
|
<textarea
|
|
aria-label="Message Zopu"
|
|
placeholder="Message Zopu..."
|
|
rows={1}
|
|
className={cn(
|
|
"min-h-10 max-h-28 flex-1 resize-none rounded-[20px] border-0 bg-[#f4f4f2] px-4 py-[10px] text-[15px] leading-5 text-[#232321] outline-none placeholder:text-center placeholder:text-[#b6b7b4] focus-visible:ring-2 focus-visible:ring-[#171716]/15 disabled:cursor-not-allowed disabled:opacity-60",
|
|
textareaClassName
|
|
)}
|
|
{...inputProps}
|
|
/>
|
|
<button
|
|
aria-label="Send message"
|
|
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-[#0d0d0c] text-[#fafafa] transition-transform active:scale-[0.96] disabled:bg-[#dededb] disabled:text-[#9b9c99]"
|
|
disabled={!canSend}
|
|
type="submit"
|
|
>
|
|
{busy ? (
|
|
<LoaderCircle className="size-[18px] animate-spin" />
|
|
) : (
|
|
<ArrowUp className="size-[18px]" strokeWidth={2.2} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</MobileChatComposerDock>
|
|
);
|
|
};
|
|
|
|
export {
|
|
MobileChatAssistantLabel,
|
|
MobileChatBubble,
|
|
MobileChatComposer,
|
|
MobileChatComposerDock,
|
|
MobileChatHeader,
|
|
MobileChatMark,
|
|
MobileChatMessage,
|
|
MobileChatToolResult,
|
|
MobileChatToolCall,
|
|
MobileViewport,
|
|
};
|
|
export type {
|
|
MobileChatAssistantLabelProps,
|
|
MobileChatBubbleProps,
|
|
MobileChatComposerProps,
|
|
MobileChatComposerDockProps,
|
|
MobileChatHeaderProps,
|
|
MobileChatMessageProps,
|
|
MobileChatToolCallProps,
|
|
MobileChatToolResultProps,
|
|
};
|