diff --git a/src/admin/components/odin/OdinChat.tsx b/src/admin/components/odin/OdinChat.tsx index 21000ba..3007661 100644 --- a/src/admin/components/odin/OdinChat.tsx +++ b/src/admin/components/odin/OdinChat.tsx @@ -17,7 +17,7 @@ import type { EditableField, StagedFile, } from "./types"; -import OdinTabs from "./OdinTabs"; +import OdinSidebar from "./OdinSidebar"; import OdinThread from "./OdinThread"; import InvoiceReviewCard from "./InvoiceReviewCard"; import OdinComposer from "./OdinComposer"; @@ -87,13 +87,10 @@ export default function OdinChat() { const threadRef = useRef(null); const seededId = useRef(null); - // Select the first existing conversation once the list loads (lazy-create: - // a brand-new user has none until they send the first message or press "+"). - useEffect(() => { - if (!convPending && activeId == null && conversations.length > 0) { - setActiveId(conversations[0].id); - } - }, [convPending, conversations, activeId]); + // No auto-select: like claude.ai, we land on a fresh "new chat" (activeId + // null) and the sidebar lists existing conversations to open. A conversation + // row in the DB is created lazily on the first message (see submit), so + // pressing "Nová konverzace" repeatedly never spawns empty conversations. // Seed the thread from the active conversation's messages, once per id. useEffect(() => { @@ -185,13 +182,6 @@ export default function OdinChat() { if (!text && files.length === 0) return; setBusy(true); - const convId = await ensureActive(); - if (convId == null) { - setBusy(false); - alert.error("Nepodařilo se vytvořit konverzaci"); - return; - } - seededId.current = convId; const prevTurns = turns; const userContent = [ @@ -247,10 +237,13 @@ export default function OdinChat() { setTurns((t) => [...t, { role: "assistant", content: note }]); setInput(""); setAttachments([]); - persist(convId, [ - { role: "user", content: userContent }, - { role: "assistant", content: note }, - ]); + // Create the conversation now (first successful message) and persist. + const convId = await ensureActive(); + if (convId != null) + persist(convId, [ + { role: "user", content: userContent }, + { role: "assistant", content: note }, + ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } else { let ctx = optimistic.slice(-MODEL_CONTEXT); @@ -265,10 +258,13 @@ export default function OdinChat() { const reply: string = body.data.reply; setTurns((t) => [...t, { role: "assistant", content: reply }]); setInput(""); - persist(convId, [ - { role: "user", content: userContent }, - { role: "assistant", content: reply }, - ]); + // Create the conversation now (first successful message) and persist. + const convId = await ensureActive(); + if (convId != null) + persist(convId, [ + { role: "user", content: userContent }, + { role: "assistant", content: reply }, + ]); qc.invalidateQueries({ queryKey: ["ai", "usage"] }); } } catch (e) { @@ -317,24 +313,16 @@ export default function OdinChat() { } }; - const onNew = async () => { + // "Nová konverzace" just opens a fresh, empty composer — no DB row is created + // until the first message, so repeated clicks never spawn empty conversations. + const onNew = () => { if (busy) return; - const res = await apiFetch("/api/admin/ai/conversations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - const b = await res.json(); - if (res.ok && b?.data?.conversation) { - const id = b.data.conversation.id as number; - seededId.current = id; - setActiveId(id); - setTurns([]); - setReview([]); - qc.invalidateQueries({ queryKey: ["ai", "conversations"] }); - } else { - alert.error("Nepodařilo se vytvořit konverzaci"); - } + setActiveId(null); + setTurns([]); + setReview([]); + setInput(""); + setAttachments([]); + seededId.current = null; }; const onRename = async (id: number, title: string) => { @@ -356,8 +344,8 @@ export default function OdinChat() { return; } if (id === activeId) { - const next = conversations.find((c) => c.id !== id)?.id ?? null; - setActiveId(next); + // Deleting the open conversation returns to a fresh new chat. + setActiveId(null); setTurns([]); setReview([]); seededId.current = null; @@ -370,55 +358,22 @@ export default function OdinChat() { r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)), ); + const activeTitle = + conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace"; + return ( - - - - AI - - - Odin - - - {usage && ( - - Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ - {usage.budget_usd.toFixed(2)} - - )} - - - - - - {review.length > 0 && ( + {/* Chat column */} + - {review.map((inv) => ( - - setReview((r) => r.filter((x) => x.uid !== uid)) - } - /> - ))} + + {activeTitle} + + {usage && ( + + Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ + {usage.budget_usd.toFixed(2)} + + )} - )} - + + + {review.length > 0 && ( + + {review.map((inv) => ( + + setReview((r) => r.filter((x) => x.uid !== uid)) + } + /> + ))} + + )} + + + ); } diff --git a/src/admin/components/odin/OdinSidebar.tsx b/src/admin/components/odin/OdinSidebar.tsx new file mode 100644 index 0000000..67d6448 --- /dev/null +++ b/src/admin/components/odin/OdinSidebar.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import IconButton from "@mui/material/IconButton"; +import { Button, TextField, Modal, ConfirmDialog } from "../../ui"; + +interface OdinSidebarProps { + conversations: { id: number; title: string }[]; + activeId: number | null; + busy: boolean; + onSelect: (id: number) => void; + onNew: () => void; + onRename: (id: number, title: string) => void; + onDelete: (id: number) => void; +} + +export default function OdinSidebar({ + conversations, + activeId, + busy, + onSelect, + onNew, + onRename, + onDelete, +}: OdinSidebarProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [menuConv, setMenuConv] = useState<{ + id: number; + title: string; + } | null>(null); + const [renameOpen, setRenameOpen] = useState(false); + const [renameDraft, setRenameDraft] = useState(""); + const [deleteOpen, setDeleteOpen] = useState(false); + + const openMenu = ( + e: React.MouseEvent, + conv: { id: number; title: string }, + ) => { + e.stopPropagation(); + setMenuConv(conv); + setAnchorEl(e.currentTarget); + }; + const closeMenu = () => setAnchorEl(null); + + const handleRenameOpen = () => { + closeMenu(); + setRenameDraft(menuConv?.title ?? ""); + setRenameOpen(true); + }; + const handleRenameSubmit = () => { + if (menuConv) onRename(menuConv.id, renameDraft.trim() || menuConv.title); + setRenameOpen(false); + }; + const handleDeleteOpen = () => { + closeMenu(); + setDeleteOpen(true); + }; + const handleDeleteConfirm = () => { + if (menuConv) onDelete(menuConv.id); + setDeleteOpen(false); + }; + + return ( + + {/* Brand + New chat */} + + + + AI + + + Odin + + + + + + {/* Conversation list */} + + {conversations.length === 0 && ( + + Zatím žádné konverzace. + + )} + {conversations.map((conv) => { + const isActive = conv.id === activeId; + return ( + !busy && onSelect(conv.id)} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + px: 1, + py: 0.75, + borderRadius: 1.5, + cursor: busy ? "default" : "pointer", + bgcolor: isActive ? "action.selected" : "transparent", + "&:hover": { + bgcolor: isActive ? "action.selected" : "action.hover", + }, + "&:hover .odin-conv-menu": { opacity: 1 }, + }} + > + + {conv.title} + + openMenu(e, conv)} + aria-label="Možnosti konverzace" + title="Možnosti konverzace" + sx={{ + flexShrink: 0, + opacity: { xs: 1, md: 0 }, + transition: "opacity 0.15s", + }} + > + ⋮ + + + ); + })} + + + {/* Per-conversation menu */} + + Přejmenovat + Smazat + + + {/* Rename modal */} + setRenameOpen(false)} + onSubmit={handleRenameSubmit} + title="Přejmenovat konverzaci" + submitText="Přejmenovat" + maxWidth="xs" + > + + setRenameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleRenameSubmit(); + } + }} + autoFocus + /> + + + + {/* Delete confirm */} + setDeleteOpen(false)} + onConfirm={handleDeleteConfirm} + title="Smazat konverzaci" + message={`Opravdu chcete smazat konverzaci „${menuConv?.title ?? ""}"? Tuto akci nelze vrátit.`} + confirmText="Smazat" + confirmVariant="danger" + /> + + ); +} diff --git a/src/admin/components/odin/OdinTabs.tsx b/src/admin/components/odin/OdinTabs.tsx deleted file mode 100644 index 327db05..0000000 --- a/src/admin/components/odin/OdinTabs.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState } from "react"; -import Box from "@mui/material/Box"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import IconButton from "@mui/material/IconButton"; -import { Button, TextField, Modal, ConfirmDialog } from "../../ui"; - -interface OdinTabsProps { - conversations: { id: number; title: string }[]; - activeId: number | null; - busy: boolean; - onSelect: (id: number) => void; - onNew: () => void; - onRename: (id: number, title: string) => void; - onDelete: (id: number) => void; -} - -export default function OdinTabs({ - conversations, - activeId, - busy, - onSelect, - onNew, - onRename, - onDelete, -}: OdinTabsProps) { - const [anchorEl, setAnchorEl] = useState(null); - const [renameOpen, setRenameOpen] = useState(false); - const [renameDraft, setRenameDraft] = useState(""); - const [deleteOpen, setDeleteOpen] = useState(false); - - const activeConv = conversations.find((c) => c.id === activeId) ?? null; - - const openMenu = (e: React.MouseEvent) => { - e.stopPropagation(); - setAnchorEl(e.currentTarget); - }; - const closeMenu = () => setAnchorEl(null); - - const handleRenameOpen = () => { - closeMenu(); - setRenameDraft(activeConv?.title ?? ""); - setRenameOpen(true); - }; - - const handleRenameSubmit = () => { - if (activeId !== null) { - onRename(activeId, renameDraft.trim() || (activeConv?.title ?? "")); - } - setRenameOpen(false); - }; - - const handleDeleteOpen = () => { - closeMenu(); - setDeleteOpen(true); - }; - - const handleDeleteConfirm = () => { - if (activeId !== null) { - onDelete(activeId); - } - setDeleteOpen(false); - }; - - return ( - <> - - {conversations.map((conv) => { - const isActive = conv.id === activeId; - return ( - - - {isActive && ( - - ⋮ - - )} - - ); - })} - - - - - {/* Overflow menu */} - - Přejmenovat - Smazat - - - {/* Rename modal */} - setRenameOpen(false)} - onSubmit={handleRenameSubmit} - title="Přejmenovat konverzaci" - submitText="Přejmenovat" - maxWidth="xs" - > - - setRenameDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleRenameSubmit(); - } - }} - autoFocus - /> - - - - {/* Delete confirm */} - setDeleteOpen(false)} - onConfirm={handleDeleteConfirm} - title="Smazat konverzaci" - message={`Opravdu chcete smazat konverzaci „${activeConv?.title ?? ""}"? Tuto akci nelze vrátit.`} - confirmText="Smazat" - confirmVariant="danger" - /> - - ); -}