diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts new file mode 100644 index 00000000..2e91a277 --- /dev/null +++ b/ui/src/api/chat.ts @@ -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(`/api/companies/${companyId}/conversations${qs ? `?${qs}` : ""}`); + }, + createConversation: (companyId: string, data?: { title?: string }) => + api.post(`/api/companies/${companyId}/conversations`, data ?? {}), + getConversation: (id: string) => + api.get(`/api/conversations/${id}`), + updateConversation: (id: string, data: { title?: string }) => + api.patch(`/api/conversations/${id}`, data), + deleteConversation: (id: string) => + api.delete(`/api/conversations/${id}`), + archiveConversation: (id: string) => + api.post(`/api/conversations/${id}/archive`, {}), + unarchiveConversation: (id: string) => + api.post(`/api/conversations/${id}/unarchive`, {}), + pinConversation: (id: string) => + api.post(`/api/conversations/${id}/pin`, {}), + unpinConversation: (id: string) => + api.post(`/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(`/api/conversations/${conversationId}/messages`, data), +}; diff --git a/ui/src/context/ChatPanelContext.tsx b/ui/src/context/ChatPanelContext.tsx new file mode 100644 index 00000000..469a46ce --- /dev/null +++ b/ui/src/context/ChatPanelContext.tsx @@ -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(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(null); + + const setChatOpen = useCallback((open: boolean) => { + setChatOpenState(open); + writePreference(open); + }, []); + + const toggleChat = useCallback(() => { + setChatOpenState((prev) => { + const next = !prev; + writePreference(next); + return next; + }); + }, []); + + return ( + + {children} + + ); +} + +export function useChatPanel() { + const ctx = useContext(ChatPanelContext); + if (!ctx) throw new Error("useChatPanel must be used within ChatPanelProvider"); + return ctx; +} diff --git a/ui/src/hooks/useChatConversations.ts b/ui/src/hooks/useChatConversations.ts new file mode 100644 index 00000000..cffaea7c --- /dev/null +++ b/ui/src/hooks/useChatConversations.ts @@ -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"] }), + }), + }; +} diff --git a/ui/src/hooks/useChatMessages.ts b/ui/src/hooks/useChatMessages.ts new file mode 100644 index 00000000..14dcc9ca --- /dev/null +++ b/ui/src/hooks/useChatMessages.ts @@ -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"] }); + }, + }); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 1292810d..49982f51 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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( - - - - - + + + + + + +