Organize/components/ai/chat-thread.tsx

139 lines
3.9 KiB
TypeScript

"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Mic, Send, Square } from "lucide-react";
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
export interface ChatBubbleMessage {
role: "user" | "assistant";
content: string;
}
/**
* Shared message-bubble list for the two plain-text chat surfaces (the
* global Chat page and the group Status Update follow-up Q&A) -- both
* just render a back-and-forth conversation with no ask/finalize review
* step, unlike GroupAiDialog/TodoAiDialog (which inline their own nearly
* identical list because they also render an editable "pending" state
* this shared component doesn't need to know about).
*/
export function ChatMessageList({
messages,
sending,
error,
emptyHint,
colorMode,
}: {
messages: ChatBubbleMessage[];
sending: boolean;
error: string | null;
emptyHint: string;
colorMode?: "light" | "dark";
}) {
return (
<div className="flex-1 space-y-3 overflow-y-auto">
{messages.length === 0 ? (
<p className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
{emptyHint}
</p>
) : (
messages.map((message, index) => (
<div
key={index}
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
message.role === "user"
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
data-color-mode={message.role === "assistant" ? colorMode : undefined}
>
{message.role === "assistant" ? (
<MarkdownPreview source={message.content} />
) : (
message.content
)}
</div>
))
)}
{sending && (
<div className="max-w-[85%] rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
Thinking
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
/**
* Shared textarea + mic + send composer, matching the input row already
* used by GroupAiDialog/TodoAiDialog (voice input via
* useSpeechRecognition, Enter-to-send, Shift+Enter for a newline).
*/
export function ChatComposer({
value,
onChange,
onSend,
sending,
listening,
micSupported,
onStartListening,
onStopListening,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
onSend: () => void;
sending: boolean;
listening: boolean;
micSupported: boolean;
onStartListening: () => void;
onStopListening: () => void;
placeholder?: string;
}) {
return (
<div className="flex items-end gap-2">
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
}}
placeholder={placeholder ?? (listening ? "Listening…" : "Type or use the microphone…")}
rows={2}
className="flex-1 resize-none"
disabled={sending}
/>
<div className="flex flex-col gap-2">
{micSupported && (
<Button
type="button"
variant={listening ? "destructive" : "outline"}
size="icon"
onClick={listening ? onStopListening : onStartListening}
disabled={sending}
aria-label={listening ? "Stop recording" : "Record voice input"}
>
{listening ? <Square className="size-4" /> : <Mic className="size-4" />}
</Button>
)}
<Button
type="button"
size="icon"
onClick={onSend}
disabled={sending || !value.trim()}
aria-label="Send"
>
<Send className="size-4" />
</Button>
</div>
</div>
);
}