feat(25-03): wire files into ChatMessage, ChatPanel, and server listMessages

- ChatMessage: files prop, renders ChatFilePreview for each attached file
- ChatMessageList: passes files prop through to ChatMessage
- ChatPanel: wires useChatFileUpload, passes pendingFiles/onRemoveFile/onFilesPicked to ChatInput
- ChatPanel handleSend: attaches uploaded files to message after creation, invalidates query
- chatApi: adds attachFilesToMessage (parallel PATCH /files/:fileId for each fileId)
- server/chat.ts: listMessages fetches chatFiles by messageId (inArray), attaches files array
- server/chat.ts: addMessage returns files: [] for consistency
This commit is contained in:
Nexus Dev 2026-04-01 23:33:00 +00:00
parent 3e13ac88dc
commit 35a7814032
5 changed files with 86 additions and 7 deletions

View file

@ -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<string, typeof chatFiles.$inferSelect[]>();
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) {

View file

@ -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,

View file

@ -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 && (
<div className="mt-2 flex flex-col gap-2">
{files.map((f) => (
<ChatFilePreview
key={f.id}
file={f}
contentPath={`/api/files/${f.id}/content`}
/>
))}
</div>
)}
<ChatMessageActions
role="user"
isStreaming={isAnyStreaming}
@ -160,6 +174,17 @@ export function ChatMessage({
/>
)}
<ChatMarkdownMessage content={content} />
{files && files.length > 0 && (
<div className="mt-2 flex flex-col gap-2">
{files.map((f) => (
<ChatFilePreview
key={f.id}
file={f}
contentPath={`/api/files/${f.id}/content`}
/>
))}
</div>
)}
{isStreaming && <ChatStreamingCursor />}
<ChatMessageActions
role="assistant"

View file

@ -177,6 +177,7 @@ export function ChatMessageList({
onHandoff={onHandoff}
onBookmark={onBookmark}
isBookmarked={msg.id ? (bookmarkedMessageIds?.has(msg.id) ?? false) : false}
files={msg.files}
/>
</div>
);

View file

@ -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)}
/>
</div>
</div>