diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6ce03ed..ee7b65a 100755 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -69,6 +69,30 @@ body::after { user-select: none; } +.mentor-thinking-dots span { + animation: mentor-dot-pulse 1.4s infinite; + opacity: 0.3; +} + +.mentor-thinking-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.mentor-thinking-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes mentor-dot-pulse { + 0%, + 80%, + 100% { + opacity: 0.3; + } + 40% { + opacity: 1; + } +} + .retro-btn { background: linear-gradient(180deg, var(--color-surface-raised) 0%, var(--color-surface) 100%); border: 2px solid; diff --git a/apps/web/src/app/mentor/page.tsx b/apps/web/src/app/mentor/page.tsx index 74d2d1d..ddd3180 100644 --- a/apps/web/src/app/mentor/page.tsx +++ b/apps/web/src/app/mentor/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { AppShell } from "@/components/layout/app-shell"; +import { MentorChatMessages } from "@/components/features/mentor-chat-messages"; +import { createChatSession, fetchChatSessions, useMentorChat } from "@/hooks/useMentorChat"; import Link from "next/link"; const SUGGESTED = [ @@ -16,40 +18,37 @@ const SUGGESTED = [ export default function MentorPage() { const [sessionId, setSessionId] = useState(null); - const [input, setInput] = useState(""); const [tab, setTab] = useState<"chat" | "knows" | "preview">("chat"); - const bottomRef = useRef(null); + const [previewInput, setPreviewInput] = useState(""); const { data: sessionsData } = useQuery({ queryKey: ["chat-sessions"], - queryFn: async () => { - const res = await fetch("/api/ai/chat/sessions"); - return res.json(); - }, + queryFn: fetchChatSessions, }); useEffect(() => { if (!sessionId && sessionsData?.sessions?.[0]) { setSessionId(sessionsData.sessions[0].id); } else if (!sessionId) { - fetch("/api/ai/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }) - .then((r) => r.json()) - .then((d) => setSessionId(d.id)); + createChatSession().then((d) => setSessionId(d.id)); } }, [sessionId, sessionsData]); - const { data: chatData, refetch } = useQuery({ - queryKey: ["chat-session", sessionId], - queryFn: async () => { - const res = await fetch(`/api/ai/chat/sessions/${sessionId}`); - return res.json(); - }, - enabled: !!sessionId, - }); + const chat = useMentorChat(sessionId); + + useEffect(() => { + if (!chat.archiveSuccess || !sessionId) return; + chat.resetArchiveSuccess(); + + const remaining = (sessionsData?.sessions ?? []).filter( + (s: { id: string }) => s.id !== sessionId + ); + if (remaining.length > 0) { + setSessionId(remaining[0].id); + } else { + createChatSession().then((d) => setSessionId(d.id)); + } + }, [chat.archiveSuccess, sessionId, sessionsData, chat.resetArchiveSuccess]); const { data: knowsData } = useQuery({ queryKey: ["ai-knows"], @@ -61,45 +60,20 @@ export default function MentorPage() { }); const { data: previewData, refetch: refetchPreview } = useQuery({ - queryKey: ["context-preview", input], + queryKey: ["context-preview", previewInput], queryFn: async () => { - const params = new URLSearchParams({ message: input || "hello", feature: "mentor" }); + const params = new URLSearchParams({ message: previewInput || "hello", feature: "mentor" }); const res = await fetch(`/api/ai/context/preview?${params}`); return res.json(); }, enabled: tab === "preview", }); - const send = useMutation({ - mutationFn: async (content: string) => { - const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!res.ok) throw new Error("Send failed"); - return res.json(); - }, - onSuccess: () => { - refetch(); - setInput(""); - }, - }); - const newSession = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/ai/chat/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); - return res.json(); - }, + mutationFn: () => createChatSession(), onSuccess: (d) => setSessionId(d.id), }); - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [chatData?.messages?.length]); - - const messages = chatData?.messages ?? []; - return (
@@ -141,17 +115,29 @@ export default function MentorPage() { ))}
+
+ + {sessionsData?.sessions?.find((s: { id: string }) => s.id === sessionId)?.title ?? "Chat"} + + +
+ {chat.deleteError && ( +

{chat.deleteError}

+ )}
- {messages.map((m: { id: string; role: string; content: string; metadata?: { offline?: boolean } }) => ( -
- {m.role === "user" ? "You" : "Mentor"} - {m.metadata?.offline && ( - (offline/local) - )} -

{m.content}

-
- ))} -
+
{SUGGESTED.map((q) => ( @@ -159,8 +145,8 @@ export default function MentorPage() { key={q} type="button" className="retro-btn text-[10px]" - onClick={() => send.mutate(q)} - disabled={send.isPending} + onClick={() => chat.sendMessage(q)} + disabled={!sessionId || chat.isSendPending} > {q} @@ -169,18 +155,19 @@ export default function MentorPage() {
setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())} + value={chat.input} + onChange={(e) => chat.setInput(e.target.value)} + onKeyDown={chat.handleKeyDown} placeholder="Ask the mentor..." + disabled={!sessionId} />
@@ -216,8 +203,8 @@ export default function MentorPage() { setInput(e.target.value)} + value={previewInput} + onChange={(e) => setPreviewInput(e.target.value)} /> + )} +
+ ); +} + +function MentorMessageBubble({ + message, + compact, +}: { + message: MentorChatMessage; + compact?: boolean; +}) { + const isUser = message.role === "user"; + + return ( +
+ + {isUser ? "You" : "Mentor"} + + {message.metadata?.offline && ( + (offline/local) + )} +

{message.content}

+
+ ); +} + +export function MentorChatMessages({ + messages, + emptyMessage, + onRetry, + retryPending, + compact, + bottomRef, +}: { + messages: MentorChatMessage[]; + emptyMessage?: string; + onRetry?: () => void; + retryPending?: boolean; + compact?: boolean; + bottomRef?: RefObject; +}) { + return ( + <> + {messages.length === 0 && emptyMessage && ( +

+ {emptyMessage} +

+ )} + {messages.map((m) => { + if (m.status === "thinking") { + return ; + } + if (m.status === "error") { + return ( + + ); + } + return ; + })} +
+ + ); +} diff --git a/apps/web/src/components/features/mentor-panel.tsx b/apps/web/src/components/features/mentor-panel.tsx index 89d1e0e..a85589d 100644 --- a/apps/web/src/components/features/mentor-panel.tsx +++ b/apps/web/src/components/features/mentor-panel.tsx @@ -1,8 +1,9 @@ "use client"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; +import { MentorChatMessages } from "@/components/features/mentor-chat-messages"; +import { createChatSession, useMentorChat } from "@/hooks/useMentorChat"; const SUGGESTED = [ "What should I focus on today?", @@ -14,58 +15,35 @@ const SUGGESTED = [ export function MentorPanel({ onClose }: { onClose?: () => void }) { const [sessionId, setSessionId] = useState(null); - const [input, setInput] = useState(""); - const bottomRef = useRef(null); - const qc = useQueryClient(); useEffect(() => { if (!sessionId) { - fetch("/api/ai/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "Quick chat" }), - }) - .then((r) => r.json()) - .then((d) => setSessionId(d.id)); + createChatSession("Quick chat").then((d) => setSessionId(d.id)); } }, [sessionId]); - const { data, refetch } = useQuery({ - queryKey: ["chat-session", sessionId], - queryFn: async () => { - const res = await fetch(`/api/ai/chat/sessions/${sessionId}`); - return res.json(); - }, - enabled: !!sessionId, - }); - - const send = useMutation({ - mutationFn: async (content: string) => { - const res = await fetch(`/api/ai/chat/sessions/${sessionId}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); - if (!res.ok) throw new Error("Send failed"); - return res.json(); - }, - onSuccess: () => { - refetch(); - setInput(""); - }, - }); + const chat = useMentorChat(sessionId); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [data?.messages?.length]); - - const messages = data?.messages ?? []; + if (!chat.archiveSuccess) return; + chat.resetArchiveSuccess(); + setSessionId(null); + createChatSession("Quick chat").then((d) => setSessionId(d.id)); + }, [chat.archiveSuccess, chat.resetArchiveSuccess]); return (
Mentor -
+
+ Open full @@ -76,22 +54,18 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) { )}
+ {chat.deleteError && ( +

{chat.deleteError}

+ )}
- {messages.length === 0 && ( -

- Ask anything about your journey. I use your saved memories — nothing hidden. -

- )} - {messages.map((m: { id: string; role: string; content: string }) => ( -
- {m.role === "user" ? "You" : "Mentor"} - {m.content} -
- ))} -
+
{SUGGESTED.slice(0, 3).map((q) => ( @@ -99,8 +73,8 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) { key={q} type="button" className="retro-btn text-[10px]" - onClick={() => send.mutate(q)} - disabled={!sessionId || send.isPending} + onClick={() => chat.sendMessage(q)} + disabled={!sessionId || chat.isSendPending} > {q} @@ -109,18 +83,19 @@ export function MentorPanel({ onClose }: { onClose?: () => void }) {
setInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && input.trim() && send.mutate(input.trim())} + value={chat.input} + onChange={(e) => chat.setInput(e.target.value)} + onKeyDown={chat.handleKeyDown} placeholder="Ask the mentor..." + disabled={!sessionId} />
diff --git a/apps/web/src/components/features/template-editor.tsx b/apps/web/src/components/features/template-editor.tsx index 5a087c5..088809e 100755 --- a/apps/web/src/components/features/template-editor.tsx +++ b/apps/web/src/components/features/template-editor.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import type { DragEvent } from "react"; import type { TemplateData, AdventureItemType } from "@adventureos/shared"; const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; @@ -14,10 +15,19 @@ const ITEM_TYPES: AdventureItemType[] = [ "note", ]; +function moveEntry(entries: T[], fromIndex: number, toIndex: number) { + const next = [...entries]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; +} + export function TemplateEditor() { const qc = useQueryClient(); const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); + const [draggedTemplateId, setDraggedTemplateId] = useState(null); + const [draggedItemId, setDraggedItemId] = useState(null); const { data: templates = [], isLoading } = useQuery({ queryKey: ["templates"], @@ -60,6 +70,32 @@ export function TemplateEditor() { onSuccess: invalidate, }); + const reorderTemplates = useMutation({ + mutationFn: async (ordered: TemplateData[]) => { + await Promise.all( + ordered.map((template, index) => + fetch(`/api/templates/${template.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sortPriority: ordered.length - index }), + }).then((res) => { + if (!res.ok) throw new Error("Failed"); + }) + ) + ); + }, + onMutate: async (ordered) => { + await qc.cancelQueries({ queryKey: ["templates"] }); + const previous = qc.getQueryData(["templates"]); + qc.setQueryData(["templates"], ordered); + return { previous }; + }, + onError: (_error, _ordered, context) => { + if (context?.previous) qc.setQueryData(["templates"], context.previous); + }, + onSettled: invalidate, + }); + const duplicateTemplate = useMutation({ mutationFn: async (id: string) => { const res = await fetch(`/api/templates/${id}`, { @@ -102,6 +138,44 @@ export function TemplateEditor() { onSuccess: invalidate, }); + const reorderItems = useMutation({ + mutationFn: async (orderedItems: TemplateData["items"]) => { + if (!selected) throw new Error("No template selected"); + await Promise.all( + orderedItems.map((item, index) => + fetch(`/api/templates/${selected.id}/items`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: item.id, + type: item.type, + label: item.label, + config: item.config, + sortOrder: index, + enabled: item.enabled, + }), + }).then((res) => { + if (!res.ok) throw new Error("Failed"); + }) + ) + ); + }, + onMutate: async (orderedItems) => { + await qc.cancelQueries({ queryKey: ["templates"] }); + const previous = qc.getQueryData(["templates"]); + qc.setQueryData(["templates"], (current = []) => + current.map((template) => + template.id === selected?.id ? { ...template, items: orderedItems } : template + ) + ); + return { previous }; + }, + onError: (_error, _ordered, context) => { + if (context?.previous) qc.setQueryData(["templates"], context.previous); + }, + onSettled: invalidate, + }); + const deleteItem = useMutation({ mutationFn: async ({ templateId, itemId }: { templateId: string; itemId: string }) => { const res = await fetch( @@ -115,6 +189,34 @@ export function TemplateEditor() { if (isLoading) return

Loading templates...

; + const handleTemplateDrop = (event: DragEvent, targetId: string) => { + event.preventDefault(); + if (!draggedTemplateId || draggedTemplateId === targetId) return; + + const fromIndex = templates.findIndex((template) => template.id === draggedTemplateId); + const toIndex = templates.findIndex((template) => template.id === targetId); + if (fromIndex === -1 || toIndex === -1) return; + + reorderTemplates.mutate(moveEntry(templates, fromIndex, toIndex)); + setDraggedTemplateId(null); + }; + + const handleItemDrop = (event: DragEvent, targetId: string) => { + event.preventDefault(); + if (!selected || !draggedItemId || draggedItemId === targetId) return; + + const fromIndex = selected.items.findIndex((item) => item.id === draggedItemId); + const toIndex = selected.items.findIndex((item) => item.id === targetId); + if (fromIndex === -1 || toIndex === -1) return; + + const orderedItems = moveEntry(selected.items, fromIndex, toIndex).map((item, index) => ({ + ...item, + sortOrder: index, + })); + reorderItems.mutate(orderedItems); + setDraggedItemId(null); + }; + return (

Adventure Templates

@@ -126,9 +228,20 @@ export function TemplateEditor() { {templates.map((t) => ( @@ -201,7 +314,27 @@ export function TemplateEditor() {

Items

{selected.items.map((item) => ( -
+
event.preventDefault()} + onDrop={(event) => handleItemDrop(event, item.id)} + > +