diff --git a/server/src/services/chat.ts b/server/src/services/chat.ts index 0ccb0004..d0e71e38 100644 --- a/server/src/services/chat.ts +++ b/server/src/services/chat.ts @@ -1,6 +1,6 @@ -import { and, asc, desc, eq, gt, ilike, isNull, lt, lte, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, ilike, inArray, isNull, lt, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, chatConversations, chatMessageBookmarks, chatMessages } from "@paperclipai/db"; +import { agents, chatConversations, chatFiles, chatMessageBookmarks, chatMessages } from "@paperclipai/db"; import { notFound } from "../errors.js"; export function chatService(db: Db) { @@ -132,7 +132,33 @@ export function chatService(db: Db) { const items = hasMore ? rows.slice(0, limit) : rows; const nextCursor = hasMore ? items[items.length - 1]!.createdAt.toISOString() : null; - return { items, hasMore, nextCursor }; + // Load files for these messages and attach them + const messageIds = items.map((m) => m.id); + let filesByMessageId = new Map(); + if (messageIds.length > 0) { + const fileRows = await db + .select() + .from(chatFiles) + .where(inArray(chatFiles.messageId, messageIds)) + .orderBy(asc(chatFiles.createdAt)); + for (const f of fileRows) { + if (!f.messageId) continue; + const existing = filesByMessageId.get(f.messageId) ?? []; + existing.push(f); + filesByMessageId.set(f.messageId, existing); + } + } + + const itemsWithFiles = items.map((m) => ({ + ...m, + files: (filesByMessageId.get(m.id) ?? []).map((f) => ({ + ...f, + createdAt: f.createdAt.toISOString(), + updatedAt: f.updatedAt.toISOString(), + })), + })); + + return { items: itemsWithFiles, hasMore, nextCursor }; }, async addMessage( @@ -164,7 +190,7 @@ export function chatService(db: Db) { .where(and(eq(chatConversations.id, conversationId), isNull(chatConversations.title))); } - return message!; + return { ...message!, files: [] }; }, async editMessage(messageId: string, content: string) { diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index e584df62..a7a4db0b 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -213,6 +213,14 @@ export const chatApi = { return `/api/conversations/${conversationId}/export?format=${format}`; }, + async attachFilesToMessage(fileIds: string[], messageId: string) { + await Promise.all( + fileIds.map((fileId) => + api.patch(`/files/${fileId}`, { messageId }) + ) + ); + }, + async uploadFile( conversationId: string, file: File, diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index febae610..32558e99 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -7,9 +7,10 @@ import { ChatSpecCard } from "./ChatSpecCard"; import { ChatHandoffIndicator } from "./ChatHandoffIndicator"; import { ChatTaskCreatedBadge } from "./ChatTaskCreatedBadge"; import { ChatStatusUpdateBadge } from "./ChatStatusUpdateBadge"; +import { ChatFilePreview } from "./ChatFilePreview"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; -import type { AgentRole } from "@paperclipai/shared"; +import type { AgentRole, ChatFile } from "@paperclipai/shared"; interface ChatMessageProps { id?: string; @@ -28,6 +29,7 @@ interface ChatMessageProps { onHandoff?: (spec: { what: string; why: string; constraints: string; success: string }) => void; onBookmark?: (messageId: string) => void; isBookmarked?: boolean; + files?: ChatFile[]; } export function ChatMessage({ @@ -47,6 +49,7 @@ export function ChatMessage({ onHandoff, onBookmark, isBookmarked, + files, }: ChatMessageProps) { const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(content); @@ -135,6 +138,17 @@ export function ChatMessage({ )} > {content} + {files && files.length > 0 && ( +
+ {files.map((f) => ( + + ))} +
+ )} )} + {files && files.length > 0 && ( +
+ {files.map((f) => ( + + ))} +
+ )} {isStreaming && } ); diff --git a/ui/src/components/ChatPanel.tsx b/ui/src/components/ChatPanel.tsx index 787f7859..8b8236d2 100644 --- a/ui/src/components/ChatPanel.tsx +++ b/ui/src/components/ChatPanel.tsx @@ -19,6 +19,7 @@ import { useChatMessages } from "../hooks/useChatMessages"; import { useStreamingChat } from "../hooks/useStreamingChat"; import { useBrainstormerDefault } from "../hooks/useBrainstormerDefault"; import { useChatBookmarks, useToggleBookmark } from "../hooks/useChatBookmarks"; +import { useChatFileUpload } from "../hooks/useChatFileUpload"; import { resolveAgentFromContent } from "../lib/slash-commands"; import type { AgentRole } from "@paperclipai/shared"; @@ -34,6 +35,7 @@ export function ChatPanel() { const { messages } = useChatMessages(activeConversationId); const { streamingContent, isStreaming, startStream, stop } = useStreamingChat(activeConversationId); + const { pendingFiles, addFile, removeFile, clearCompleted, completedFileIds } = useChatFileUpload(activeConversationId); const brainstormerDefaultId = useBrainstormerDefault(); @@ -132,6 +134,9 @@ export function ChatPanel() { // Resolve agent from slash command or @mention const resolvedAgentId = resolveAgentFromContent(content, agents, activeAgentId); + // Capture file IDs before clearing + const fileIdsToAttach = [...completedFileIds]; + setIsSending(true); try { if (!activeConversationId) { @@ -140,13 +145,24 @@ export function ChatPanel() { agentId: resolvedAgentId ?? undefined, }); setActiveConversationId(newConvo.id); - await chatApi.postMessage(newConvo.id, { role: "user", content }); + const message = await chatApi.postMessage(newConvo.id, { role: "user", content }); + // Attach any uploaded files to the message + if (fileIdsToAttach.length > 0) { + await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); + clearCompleted(); + queryClient.invalidateQueries({ queryKey: ["chat", "messages", newConvo.id] }); + } queryClient.invalidateQueries({ queryKey: ["chat"] }); // Note: streaming starts on next render when activeConversationId is set // For now, the echo stream will be triggered by the new conversation } else { // Path 2: Active conversation -- post user message then stream - await chatApi.postMessage(activeConversationId, { role: "user", content }); + const message = await chatApi.postMessage(activeConversationId, { role: "user", content }); + // Attach any uploaded files to the message + if (fileIdsToAttach.length > 0) { + await chatApi.attachFilesToMessage(fileIdsToAttach, message.id); + clearCompleted(); + } queryClient.invalidateQueries({ queryKey: ["chat", "messages", activeConversationId] }); startStream(content, resolvedAgentId ?? undefined); } @@ -369,6 +385,9 @@ export function ChatPanel() { placeholder={isStreaming ? "Waiting for response..." : "Message your agent..."} agents={agents} agentsLoading={agentsLoading} + pendingFiles={pendingFiles} + onRemoveFile={removeFile} + onFilesPicked={(files) => files.forEach(addFile)} />