From 2d7e1374ba9da4a89ec2b9a8bd362297736e7d5d Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Wed, 1 Apr 2026 16:56:33 +0000 Subject: [PATCH] feat(21-05): create chat API client and TanStack Query hooks - Add ui/src/api/chat.ts with chatApi (7 methods: list/create/get/update/delete conversations + list/post messages) - Add ui/src/hooks/useChatConversations.ts with useInfiniteQuery + placeholderData + CRUD mutations - Add ui/src/hooks/useChatMessages.ts with useInfiniteQuery + sendMutation + flattened/reversed messages array - Fix [Rule 3 - Blocking]: export ChatConversation, ChatMessage, ChatConversationListResponse, ChatMessageListResponse from packages/shared/src/index.ts (types existed in types/chat.ts but were not re-exported at package root) --- packages/shared/src/index.ts | 8 ++++ ui/src/api/chat.ts | 55 ++++++++++++++++++++++++++++ ui/src/hooks/useChatConversations.ts | 49 +++++++++++++++++++++++++ ui/src/hooks/useChatMessages.ts | 33 +++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 ui/src/api/chat.ts create mode 100644 ui/src/hooks/useChatConversations.ts create mode 100644 ui/src/hooks/useChatMessages.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0663885d..4e6e3770 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -566,6 +566,14 @@ export { type CreateMessage, } from "./validators/index.js"; +export type { + ChatConversation, + ChatConversationListItem, + ChatMessage, + ChatConversationListResponse, + ChatMessageListResponse, +} from "./types/chat.js"; + export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "./project-url-key.js"; diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts new file mode 100644 index 00000000..f3942e02 --- /dev/null +++ b/ui/src/api/chat.ts @@ -0,0 +1,55 @@ +import { api } from "./client"; +import type { + ChatConversation, + ChatConversationListResponse, + ChatMessage, + ChatMessageListResponse, +} from "@paperclipai/shared"; + +export const chatApi = { + listConversations(companyId: string, opts?: { cursor?: string; limit?: number }) { + const params = new URLSearchParams(); + if (opts?.cursor) params.set("cursor", opts.cursor); + if (opts?.limit) params.set("limit", String(opts.limit)); + const qs = params.toString(); + return api.get( + `/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`, + ); + }, + + createConversation(companyId: string, data?: { title?: string; agentId?: string }) { + return api.post(`/companies/${companyId}/conversations`, data ?? {}); + }, + + getConversation(id: string) { + return api.get(`/conversations/${id}`); + }, + + updateConversation( + id: string, + data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null }, + ) { + return api.patch(`/conversations/${id}`, data); + }, + + deleteConversation(id: string) { + return api.delete(`/conversations/${id}`); + }, + + listMessages(conversationId: string, opts?: { cursor?: string; limit?: number }) { + const params = new URLSearchParams(); + if (opts?.cursor) params.set("cursor", opts.cursor); + if (opts?.limit) params.set("limit", String(opts.limit)); + const qs = params.toString(); + return api.get( + `/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`, + ); + }, + + postMessage( + conversationId: string, + data: { role: string; content: string; agentId?: string }, + ) { + return api.post(`/conversations/${conversationId}/messages`, data); + }, +}; diff --git a/ui/src/hooks/useChatConversations.ts b/ui/src/hooks/useChatConversations.ts new file mode 100644 index 00000000..27875afd --- /dev/null +++ b/ui/src/hooks/useChatConversations.ts @@ -0,0 +1,49 @@ +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { chatApi } from "../api/chat"; +import type { ChatConversationListResponse } from "@paperclipai/shared"; + +export function useChatConversations(companyId: string | null) { + const queryClient = useQueryClient(); + + const query = useInfiniteQuery({ + queryKey: ["chat", "conversations", companyId], + queryFn: ({ pageParam }) => + chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: ChatConversationListResponse) => + lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined, + enabled: !!companyId, + placeholderData: (prev) => prev, // keepPreviousData equivalent -- prevents flicker (Pitfall 6) + }); + + const createMutation = useMutation({ + mutationFn: (data?: { title?: string }) => chatApi.createConversation(companyId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ + id, + ...data + }: { + id: string; + title?: string; + pinnedAt?: string | null; + archivedAt?: string | null; + }) => chatApi.updateConversation(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => chatApi.deleteConversation(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] }); + }, + }); + + return { ...query, createMutation, updateMutation, deleteMutation }; +} diff --git a/ui/src/hooks/useChatMessages.ts b/ui/src/hooks/useChatMessages.ts new file mode 100644 index 00000000..04945c99 --- /dev/null +++ b/ui/src/hooks/useChatMessages.ts @@ -0,0 +1,33 @@ +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { chatApi } from "../api/chat"; +import type { ChatMessage, ChatMessageListResponse } from "@paperclipai/shared"; + +export function useChatMessages(conversationId: string | null) { + const queryClient = useQueryClient(); + + const query = useInfiniteQuery({ + queryKey: ["chat", "messages", conversationId], + queryFn: ({ pageParam }) => + chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: ChatMessageListResponse) => + lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined, + enabled: !!conversationId, + }); + + const sendMutation = useMutation({ + mutationFn: (data: { content: string }) => + chatApi.postMessage(conversationId!, { role: "user", content: data.content }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] }); + // Also invalidate conversations to update lastMessagePreview and sort order + queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }); + }, + }); + + // Flatten pages into a single sorted array (oldest first for display) + // Messages come from API in desc(createdAt) order -- reversing gives chronological order + const messages: ChatMessage[] = query.data?.pages.flatMap((p) => p.items).reverse() ?? []; + + return { ...query, messages, sendMutation }; +}