feat(odin): mobile composer + responsive sidebar + message-entrance animation

- Composer rebuilt claude-style: a rounded pill with a multiline auto-growing
  textarea and attach/send ICON buttons (vertically centered), so there's room
  to type on mobile (was squeezed between two text buttons).
- Conversation list collapses into a slide-in Drawer below md, with a menu
  button in the chat header; chat is full-width on mobile.
- Each new message bubble animates in (fade + slide-up; fade-only under
  prefers-reduced-motion) via framer-motion, so sent messages and Odin's
  replies appear smoothly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 21:19:54 +02:00
parent 28fb399217
commit 434a12b4a8
4 changed files with 154 additions and 43 deletions

View File

@@ -2,6 +2,9 @@ import { useState, useRef, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import useMediaQuery from "@mui/material/useMediaQuery";
import apiFetch from "../../utils/api";
import { useAlert } from "../../context/AlertContext";
import { useAuth } from "../../context/AuthContext";
@@ -100,6 +103,12 @@ export default function OdinChat() {
const threadRef = useRef<HTMLDivElement>(null);
const seededId = useRef<number | null>(null);
// Below md the conversation list collapses into a slide-in drawer.
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
noSsr: true,
});
const [sidebarOpen, setSidebarOpen] = useState(false);
// 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
@@ -161,6 +170,7 @@ export default function OdinChat() {
setAttachments((a) => a.filter((s) => s.id !== id));
const onSelect = (id: number) => {
setSidebarOpen(false);
if (id === activeId || busy) return;
setActiveId(id);
setTurns([]);
@@ -329,6 +339,7 @@ export default function OdinChat() {
// "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 = () => {
setSidebarOpen(false);
if (busy) return;
setActiveId(null);
setTurns([]);
@@ -374,6 +385,18 @@ export default function OdinChat() {
const activeTitle =
conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace";
const sidebar = (
<OdinSidebar
conversations={conversations}
activeId={activeId}
busy={busy}
onSelect={onSelect}
onNew={onNew}
onRename={onRename}
onDelete={onDelete}
/>
);
return (
<Box
sx={{
@@ -386,15 +409,17 @@ export default function OdinChat() {
overflow: "hidden",
}}
>
<OdinSidebar
conversations={conversations}
activeId={activeId}
busy={busy}
onSelect={onSelect}
onNew={onNew}
onRename={onRename}
onDelete={onDelete}
/>
{isMobile ? (
<Drawer
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
ModalProps={{ keepMounted: true }}
>
{sidebar}
</Drawer>
) : (
sidebar
)}
{/* Chat column */}
<Box
@@ -407,18 +432,33 @@ export default function OdinChat() {
p: 2,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isMobile && (
<IconButton
onClick={() => setSidebarOpen(true)}
aria-label="Konverzace"
size="small"
sx={{ ml: -0.5, flexShrink: 0 }}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 22, height: 22 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</Box>
</IconButton>
)}
<Typography
variant="subtitle1"
noWrap
sx={{ fontWeight: 600, minWidth: 0 }}
sx={{ fontWeight: 600, minWidth: 0, flex: 1 }}
>
{activeTitle}
</Typography>
@@ -426,7 +466,7 @@ export default function OdinChat() {
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0 }}
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
>
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
{usage.budget_usd.toFixed(2)}