Submatter/apps/web/components/ai/chat-mode.tsx

166 lines
5.8 KiB
TypeScript

"use client";
import { useLayoutEffect, useRef } from "react";
import { useChat } from "@ai-sdk/react";
import { AlertTriangle, Bot, Loader2, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { WorkspaceComposer } from "./workspace-composer";
import { createChatScrollMemory } from "./workspace-interaction";
export function ChatMode({
workspaceSlug,
active,
}: {
workspaceSlug: string;
active: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const scrollMemory = useRef(createChatScrollMemory());
const { messages, input, handleSubmit, status, error, setInput, stop } =
useChat({
api: "/api/chat",
body: { workspace: workspaceSlug },
});
const isBusy = status === "submitted" || status === "streaming";
useLayoutEffect(() => {
const container = scrollRef.current;
if (container) scrollMemory.current.update(container, active);
}, [messages, status, active]);
return (
<div className="flex h-full min-h-0 flex-col">
<div
ref={scrollRef}
onScroll={(event) => {
scrollMemory.current.record(event.currentTarget, active);
}}
className="min-h-0 flex-1 overflow-y-auto"
>
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 px-4 text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="size-8 text-primary" aria-hidden="true" />
</div>
<div className="text-center">
<p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm">
Ask me to break down a task, draft an outline, or summarize what
you have open.
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{[
"Break this project into subtasks",
"Summarize my open tasks",
"Draft a sprint plan for this week",
].map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="outline"
size="sm"
disabled={isBusy}
onClick={() => {
setInput(suggestion);
inputRef.current?.focus();
}}
className="h-auto rounded-full px-3 py-1.5 text-xs"
>
{suggestion}
</Button>
))}
</div>
</div>
) : (
<div
role="log"
aria-label="Chat messages"
aria-live="polite"
className="mx-auto max-w-3xl space-y-4 px-4 py-6"
>
{messages.map((msg) => (
<div
key={msg.id}
className={cn(
"flex gap-3",
msg.role === "user" && "flex-row-reverse",
)}
>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.role === "user" ? (
<User className="size-3.5" aria-hidden="true" />
) : (
<Bot className="size-3.5" aria-hidden="true" />
)}
</div>
<div
className={cn(
"max-w-[85%] rounded-xl px-4 py-3 text-sm leading-relaxed",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
<span className="sr-only">
{msg.role === "user" ? "You" : "Assistant"}:{" "}
</span>
<p className="whitespace-pre-wrap break-words">
{msg.content}
</p>
</div>
</div>
))}
{isBusy && messages[messages.length - 1]?.role === "user" && (
<div role="status" className="flex gap-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-3.5" aria-hidden="true" />
</div>
<div className="rounded-xl bg-muted px-4 py-3">
<Loader2
className="size-4 animate-spin text-muted-foreground"
aria-hidden="true"
/>
<span className="sr-only">Generating response…</span>
</div>
</div>
)}
</div>
)}
{error && (
<div role="alert" className="mx-auto max-w-3xl px-4 pb-4">
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle
className="mt-0.5 size-4 shrink-0"
aria-hidden="true"
/>
<p>
{error.message || "Something went wrong. Please try again."}
</p>
</div>
</div>
)}
</div>
<WorkspaceComposer
mode="chat"
active={active}
input={input}
onInputChange={setInput}
onSubmit={handleSubmit}
busy={isBusy}
onStop={stop}
inputRef={inputRef}
/>
</div>
);
}