139 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|