feat(web): add mobile chat component system
This commit is contained in:
334
packages/ui/src/components/mobile-chat.tsx
Normal file
334
packages/ui/src/components/mobile-chat.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
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 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;
|
||||
const message = errorMessage ?? statusMessage;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="mobile-chat-composer-dock"
|
||||
className="shrink-0 border-t border-[#e8e8e6] bg-[#fefefe] pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<form
|
||||
aria-label="Send a message"
|
||||
className={cn("w-full px-3 pt-[11px] pb-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{message ? (
|
||||
<div
|
||||
id={errorMessage ? "composer-error" : undefined}
|
||||
className={cn(
|
||||
"mb-2 px-3 text-center text-[10px] leading-4 text-[#777875]",
|
||||
errorMessage && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
MobileChatAssistantLabel,
|
||||
MobileChatBubble,
|
||||
MobileChatComposer,
|
||||
MobileChatHeader,
|
||||
MobileChatMark,
|
||||
MobileChatMessage,
|
||||
MobileChatToolResult,
|
||||
MobileChatToolCall,
|
||||
MobileViewport,
|
||||
};
|
||||
export type {
|
||||
MobileChatAssistantLabelProps,
|
||||
MobileChatBubbleProps,
|
||||
MobileChatComposerProps,
|
||||
MobileChatHeaderProps,
|
||||
MobileChatMessageProps,
|
||||
MobileChatToolCallProps,
|
||||
MobileChatToolResultProps,
|
||||
};
|
||||
Reference in New Issue
Block a user