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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
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 apiFetch from "../../utils/api";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
@@ -100,6 +103,12 @@ export default function OdinChat() {
|
|||||||
const threadRef = useRef<HTMLDivElement>(null);
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
const seededId = useRef<number | null>(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
|
// 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
|
// 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
|
// 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));
|
setAttachments((a) => a.filter((s) => s.id !== id));
|
||||||
|
|
||||||
const onSelect = (id: number) => {
|
const onSelect = (id: number) => {
|
||||||
|
setSidebarOpen(false);
|
||||||
if (id === activeId || busy) return;
|
if (id === activeId || busy) return;
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
setTurns([]);
|
setTurns([]);
|
||||||
@@ -329,6 +339,7 @@ export default function OdinChat() {
|
|||||||
// "Nová konverzace" just opens a fresh, empty composer — no DB row is created
|
// "Nová konverzace" just opens a fresh, empty composer — no DB row is created
|
||||||
// until the first message, so repeated clicks never spawn empty conversations.
|
// until the first message, so repeated clicks never spawn empty conversations.
|
||||||
const onNew = () => {
|
const onNew = () => {
|
||||||
|
setSidebarOpen(false);
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setTurns([]);
|
setTurns([]);
|
||||||
@@ -374,6 +385,18 @@ export default function OdinChat() {
|
|||||||
const activeTitle =
|
const activeTitle =
|
||||||
conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace";
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -386,15 +409,17 @@ export default function OdinChat() {
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OdinSidebar
|
{isMobile ? (
|
||||||
conversations={conversations}
|
<Drawer
|
||||||
activeId={activeId}
|
open={sidebarOpen}
|
||||||
busy={busy}
|
onClose={() => setSidebarOpen(false)}
|
||||||
onSelect={onSelect}
|
ModalProps={{ keepMounted: true }}
|
||||||
onNew={onNew}
|
>
|
||||||
onRename={onRename}
|
{sidebar}
|
||||||
onDelete={onDelete}
|
</Drawer>
|
||||||
/>
|
) : (
|
||||||
|
sidebar
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chat column */}
|
{/* Chat column */}
|
||||||
<Box
|
<Box
|
||||||
@@ -407,18 +432,33 @@ export default function OdinChat() {
|
|||||||
p: 2,
|
p: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
sx={{
|
{isMobile && (
|
||||||
display: "flex",
|
<IconButton
|
||||||
justifyContent: "space-between",
|
onClick={() => setSidebarOpen(true)}
|
||||||
alignItems: "center",
|
aria-label="Konverzace"
|
||||||
gap: 1,
|
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
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
noWrap
|
noWrap
|
||||||
sx={{ fontWeight: 600, minWidth: 0 }}
|
sx={{ fontWeight: 600, minWidth: 0, flex: 1 }}
|
||||||
>
|
>
|
||||||
{activeTitle}
|
{activeTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -426,7 +466,7 @@ export default function OdinChat() {
|
|||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ flexShrink: 0 }}
|
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
||||||
>
|
>
|
||||||
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
||||||
{usage.budget_usd.toFixed(2)}
|
{usage.budget_usd.toFixed(2)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Chip from "@mui/material/Chip";
|
import Chip from "@mui/material/Chip";
|
||||||
import { Button, TextField } from "../../ui";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
import type { StagedFile } from "./types";
|
import type { StagedFile } from "./types";
|
||||||
|
|
||||||
interface OdinComposerProps {
|
interface OdinComposerProps {
|
||||||
@@ -43,26 +44,57 @@ export default function OdinComposer({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Composer row */}
|
<input
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
ref={fileRef as React.RefObject<HTMLInputElement>}
|
||||||
<input
|
type="file"
|
||||||
ref={fileRef as React.RefObject<HTMLInputElement>}
|
accept="application/pdf,image/*"
|
||||||
type="file"
|
multiple
|
||||||
accept="application/pdf,image/*"
|
hidden
|
||||||
multiple
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
hidden
|
/>
|
||||||
onChange={(e) => onFiles(e.target.files)}
|
|
||||||
/>
|
{/* Composer pill — full-width growing input with attach + send icons */}
|
||||||
<Button
|
<Box
|
||||||
variant="outlined"
|
sx={{
|
||||||
color="inherit"
|
display: "flex",
|
||||||
disabled={busy}
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 3.5,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
pl: 0.75,
|
||||||
|
pr: 0.75,
|
||||||
|
py: 0.5,
|
||||||
|
transition: "border-color 0.15s ease",
|
||||||
|
"&:focus-within": { borderColor: "primary.main" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Přiložit fakturu"
|
||||||
|
title="Přiložit fakturu (PDF)"
|
||||||
|
sx={{ flexShrink: 0, color: "text.secondary" }}
|
||||||
>
|
>
|
||||||
Přiložit
|
<Box
|
||||||
</Button>
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 21, height: 21 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
multiline
|
||||||
|
maxRows={8}
|
||||||
|
variant="standard"
|
||||||
placeholder="Napište zprávu…"
|
placeholder="Napište zprávu…"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => onInput(e.target.value)}
|
onChange={(e) => onInput(e.target.value)}
|
||||||
@@ -72,10 +104,43 @@ export default function OdinComposer({
|
|||||||
onSubmit();
|
onSubmit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{ input: { disableUnderline: true } }}
|
||||||
|
sx={{ flex: 1, "& textarea": { py: 0.75, lineHeight: 1.5 } }}
|
||||||
/>
|
/>
|
||||||
<Button onClick={onSubmit} disabled={!canSubmit}>
|
|
||||||
Odeslat
|
<IconButton
|
||||||
</Button>
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
aria-label="Odeslat"
|
||||||
|
title="Odeslat (Enter)"
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "common.white",
|
||||||
|
bgcolor: "primary.main",
|
||||||
|
"&:hover": { bgcolor: "primary.dark" },
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
bgcolor: "action.disabledBackground",
|
||||||
|
color: "action.disabled",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 18, height: 18 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function OdinSidebar({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 184, sm: 232, md: 264 },
|
width: { xs: 280, md: 264 },
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -170,8 +170,14 @@ export default function OdinThread({
|
|||||||
{turns.map((t, i) => {
|
{turns.map((t, i) => {
|
||||||
const isUser = t.role === "user";
|
const isUser = t.role === "user";
|
||||||
return (
|
return (
|
||||||
<Box
|
<MotionBox
|
||||||
key={i}
|
key={i}
|
||||||
|
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: reduce ? 0.3 : 0.34,
|
||||||
|
ease: [0.16, 1, 0.3, 1],
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -204,7 +210,7 @@ export default function OdinThread({
|
|||||||
{t.content}
|
{t.content}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</MotionBox>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user