Organize/components/board/status-update-dialog.tsx

185 lines
5.8 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Check, Copy, Sparkles } from "lucide-react";
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
import {
converseAboutGroupStatus,
type StatusChatMessage,
} from "@/lib/actions/group-status-ai";
import type { GroupDTO } from "@/types/board";
export function StatusUpdateDialog({
group,
open,
onOpenChange,
}: {
group: GroupDTO;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const colorMode = colorModeFromTheme();
// The generated summary, shown permanently once produced -- there's no
// accept/discard step, since nothing here is ever saved back to the
// group's notes (export-only, by design).
const [summary, setSummary] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [messages, setMessages] = useState<StatusChatMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const { supported: micSupported, listening, start, stop } = useSpeechRecognition({
onFinalResult: (transcript) =>
setInput((prev) => (prev ? `${prev} ${transcript}` : transcript)),
onError: setError,
});
// Fires the initial summary the moment the dialog opens -- no user
// action needed, per spec. Guarded by summary === null so it doesn't
// re-fire on every re-render while open, and skipped once a summary (or
// an error in its place) already exists for this open session.
useEffect(() => {
if (!open || summary !== null || generating) return;
let cancelled = false;
setGenerating(true);
setError(null);
converseAboutGroupStatus(group.id, []).then((result) => {
if (cancelled) return;
setGenerating(false);
if ("error" in result) {
setError(result.error);
return;
}
setSummary(result.content);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Starts fresh every time it's reopened -- matches GroupAiDialog/
// TodoAiDialog's existing behavior.
function handleOpenChange(next: boolean) {
if (!next) {
stop();
setSummary(null);
setGenerating(false);
setCopied(false);
setMessages([]);
setInput("");
setError(null);
}
onOpenChange(next);
}
async function handleCopy() {
if (!summary) return;
try {
await navigator.clipboard.writeText(summary);
setCopied(true);
toast.success("Copied to clipboard.");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Couldn't copy to clipboard.");
}
}
async function handleSend() {
const text = input.trim();
if (!text || sending) return;
if (listening) stop();
const next: StatusChatMessage[] = [...messages, { role: "user", content: text }];
setMessages(next);
setInput("");
setError(null);
setSending(true);
const result = await converseAboutGroupStatus(group.id, next);
setSending(false);
if ("error" in result) {
setError(result.error);
return;
}
setMessages([...next, { role: "assistant", content: result.content }]);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-lg" data-color-mode={colorMode}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Status Update {group.title}
</DialogTitle>
</DialogHeader>
{generating && !summary && (
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Generating status update
</p>
)}
{error && !summary && (
<p className="text-sm text-destructive">{error}</p>
)}
{summary && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<div className="max-h-[35vh] shrink-0 overflow-y-auto rounded-md border p-4">
<MarkdownPreview source={summary} />
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit gap-2"
onClick={handleCopy}
>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? "Copied" : "Copy to clipboard"}
</Button>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<ChatMessageList
messages={messages as ChatBubbleMessage[]}
sending={sending}
error={error}
emptyHint="Ask a follow-up question about this group."
colorMode={colorMode}
/>
<ChatComposer
value={input}
onChange={setInput}
onSend={handleSend}
sending={sending}
listening={listening}
micSupported={micSupported}
onStartListening={start}
onStopListening={stop}
/>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}