'use client'; import { useEffect, useRef, useState, type FormEvent } from 'react'; import type { ChatMessageWithAttachments } from '@/lib/types'; import { AttachmentPicker } from '@/components/files/AttachmentPicker'; import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker'; import { AttachmentList } from '@/components/files/AttachmentList'; interface ChatWindowProps { projectId: number; userName: string; } export function ChatWindow({ projectId, userName }: ChatWindowProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [pickerLoading, setPickerLoading] = useState(false); const pickerRef = useRef(null); // 履歴取得 useEffect(() => { fetch(`/api/projects/${projectId}/chat/messages`) .then((res) => (res.ok ? res.json() : null)) .then((data) => { if (data?.items) setMessages(data.items as ChatMessageWithAttachments[]); }) .catch(() => undefined); }, [projectId]); // SSE接続(自動再接続はEventSourceが行う) useEffect(() => { const es = new EventSource(`/api/projects/${projectId}/chat/stream`); es.onmessage = (event) => { try { const parsed = JSON.parse(event.data) as { type: string; data: { message?: ChatMessageWithAttachments; id?: number; }; }; if (parsed.type === 'chat.message.created' && parsed.data.message) { setMessages((prev) => prev.some((m) => m.id === parsed.data.message!.id) ? prev : [parsed.data.message!, ...prev] ); } else if ( parsed.type === 'chat.message.updated' && parsed.data.message ) { // 編集イベントは本文のみ更新し、添付は既存のものを保持する setMessages((prev) => prev.map((m) => m.id === parsed.data.message!.id ? { ...m, ...parsed.data.message!, attachments: m.attachments } : m ) ); } else if (parsed.type === 'chat.message.deleted' && parsed.data.id) { setMessages((prev) => prev.filter((m) => m.id !== parsed.data.id)); } } catch { // 無効なペイロードは無視 } }; return () => es.close(); }, [projectId]); async function onSend(event: FormEvent) { event.preventDefault(); if (!input.trim()) return; setError(null); setSending(true); const fileIds = pickerRef.current?.getFileIds() ?? []; const res = await fetch(`/api/projects/${projectId}/chat/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: input, fileIds }), }); if (res.ok) { // 送信したメッセージを即座に表示(SSE到着前でも見えるように楽観追加)。 // SSEの chat.message.created は id で重複排除する。 const data = (await res.json().catch(() => null)) as { message?: ChatMessageWithAttachments; } | null; if (data?.message) { setMessages((prev) => prev.some((m) => m.id === data.message!.id) ? prev : [data.message!, ...prev] ); } setInput(''); pickerRef.current?.clear(); } else { const b = (await res.json().catch(() => null)) as { error?: { message?: string }; } | null; setError(b?.error?.message ?? '送信に失敗しました'); } setSending(false); } return (
    {messages.map((m) => (
  • {m.body} {m.attachments && m.attachments.length > 0 && (
    )} {m.createdAt}
  • ))}
setInput(e.target.value)} placeholder="メッセージを入力(@email でメンション)" className="flex-1 rounded border px-3 py-2" data-testid="chat-input" />
{error && (

{error}

)}
); }