feat(21-03): add chat API client, ChatPanelProvider, and TanStack Query hooks

- Create chatApi with full CRUD + archive/pin/message endpoints
- Create ChatPanelContext with localStorage persistence (nexus:chat-panel-open)
- Create useChatConversations with useInfiniteQuery and conversation mutations
- Create useChatMessages with useInfiniteQuery and useSendMessage
- Wrap app tree with ChatPanelProvider in main.tsx
This commit is contained in:
Mikkel Georgsen 2026-04-01 13:08:27 +02:00
parent 94a7c723de
commit 2a0724839b
5 changed files with 187 additions and 5 deletions

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

@ -0,0 +1,37 @@
import type { ChatConversation, ChatConversationListResponse, ChatMessage } from "@paperclipai/shared";
import { api } from "./client";
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>(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`);
},
createConversation: (companyId: string, data?: { title?: string }) =>
api.post<ChatConversation>(`/api/companies/${companyId}/conversations`, data ?? {}),
getConversation: (id: string) =>
api.get<ChatConversation>(`/api/conversations/${id}`),
updateConversation: (id: string, data: { title?: string }) =>
api.patch<ChatConversation>(`/api/conversations/${id}`, data),
deleteConversation: (id: string) =>
api.delete(`/api/conversations/${id}`),
archiveConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/archive`, {}),
unarchiveConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/unarchive`, {}),
pinConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/pin`, {}),
unpinConversation: (id: string) =>
api.post<ChatConversation>(`/api/conversations/${id}/unpin`, {}),
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<{ items: ChatMessage[]; hasMore: boolean }>(`/api/conversations/${conversationId}/messages${qs ? `?${qs}` : ""}`);
},
sendMessage: (conversationId: string, data: { role: string; content: string; agentId?: string | null }) =>
api.post<ChatMessage>(`/api/conversations/${conversationId}/messages`, data),
};

View file

@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
const STORAGE_KEY = "nexus:chat-panel-open";
interface ChatPanelContextValue {
chatOpen: boolean;
setChatOpen: (open: boolean) => void;
toggleChat: () => void;
activeConversationId: string | null;
setActiveConversationId: (id: string | null) => void;
}
const ChatPanelContext = createContext<ChatPanelContextValue | null>(null);
function readPreference(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw === "true";
} catch {
return false;
}
}
function writePreference(open: boolean) {
try {
localStorage.setItem(STORAGE_KEY, String(open));
} catch { /* ignore */ }
}
export function ChatPanelProvider({ children }: { children: ReactNode }) {
const [chatOpen, setChatOpenState] = useState(readPreference);
const [activeConversationId, setActiveConversationId] = useState<string | null>(null);
const setChatOpen = useCallback((open: boolean) => {
setChatOpenState(open);
writePreference(open);
}, []);
const toggleChat = useCallback(() => {
setChatOpenState((prev) => {
const next = !prev;
writePreference(next);
return next;
});
}, []);
return (
<ChatPanelContext.Provider
value={{ chatOpen, setChatOpen, toggleChat, activeConversationId, setActiveConversationId }}
>
{children}
</ChatPanelContext.Provider>
);
}
export function useChatPanel() {
const ctx = useContext(ChatPanelContext);
if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider");
return ctx;
}

View file

@ -0,0 +1,56 @@
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
export function useChatConversations(companyId: string | null) {
return useInfiniteQuery({
queryKey: ["chat", "conversations", companyId],
queryFn: ({ pageParam }) =>
chatApi.listConversations(companyId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.items.at(-1)?.updatedAt : undefined,
enabled: !!companyId,
});
}
export function useCreateConversation(companyId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data?: { title?: string }) =>
chatApi.createConversation(companyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations", companyId] });
},
});
}
export function useConversationActions() {
const queryClient = useQueryClient();
return {
pin: useMutation({
mutationFn: chatApi.pinConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
unpin: useMutation({
mutationFn: chatApi.unpinConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
archive: useMutation({
mutationFn: chatApi.archiveConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
unarchive: useMutation({
mutationFn: chatApi.unarchiveConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
remove: useMutation({
mutationFn: chatApi.deleteConversation,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
rename: useMutation({
mutationFn: ({ id, title }: { id: string; title: string }) =>
chatApi.updateConversation(id, { title }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] }),
}),
};
}

View file

@ -0,0 +1,26 @@
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { chatApi } from "../api/chat";
export function useChatMessages(conversationId: string | null) {
return useInfiniteQuery({
queryKey: ["chat", "messages", conversationId],
queryFn: ({ pageParam }) =>
chatApi.listMessages(conversationId!, { cursor: pageParam as string | undefined }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.items.at(-1)?.createdAt : undefined,
enabled: !!conversationId,
});
}
export function useSendMessage(conversationId: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (content: string) =>
chatApi.sendMessage(conversationId!, { role: "user", content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "messages", conversationId] });
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
}

View file

@ -9,6 +9,7 @@ import { CompanyProvider } from "./context/CompanyContext";
import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider";
import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext";
import { ChatPanelProvider } from "./context/ChatPanelContext";
import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext";
import { ToastProvider } from "./context/ToastContext";
@ -48,11 +49,13 @@ createRoot(document.getElementById("root")!).render(
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
<ChatPanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</ChatPanelProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>