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)
This commit is contained in:
Nexus Dev 2026-04-01 16:56:33 +00:00
parent e7411ab727
commit 2d7e1374ba
4 changed files with 145 additions and 0 deletions

View file

@ -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";

55
ui/src/api/chat.ts Normal file
View file

@ -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<ChatConversationListResponse>(
`/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`,
);
},
createConversation(companyId: string, data?: { title?: string; agentId?: string }) {
return api.post<ChatConversation>(`/companies/${companyId}/conversations`, data ?? {});
},
getConversation(id: string) {
return api.get<ChatConversation>(`/conversations/${id}`);
},
updateConversation(
id: string,
data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null },
) {
return api.patch<ChatConversation>(`/conversations/${id}`, data);
},
deleteConversation(id: string) {
return api.delete<void>(`/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<ChatMessageListResponse>(
`/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`,
);
},
postMessage(
conversationId: string,
data: { role: string; content: string; agentId?: string },
) {
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
};

View file

@ -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 };
}

View file

@ -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 };
}