feat(web): add mobile chat component system

This commit is contained in:
sai karthik
2026-07-23 17:07:17 +05:30
parent aa1c89a310
commit 3836d70704
15 changed files with 527 additions and 276 deletions

View 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,
};