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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user