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)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Box from "@mui/material/Box";
|
||||
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";
|
||||
|
||||
interface OdinComposerProps {
|
||||
@@ -43,26 +44,57 @@ export default function OdinComposer({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Composer row */}
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<input
|
||||
ref={fileRef as React.RefObject<HTMLInputElement>}
|
||||
type="file"
|
||||
accept="application/pdf,image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => onFiles(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
disabled={busy}
|
||||
<input
|
||||
ref={fileRef as React.RefObject<HTMLInputElement>}
|
||||
type="file"
|
||||
accept="application/pdf,image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => onFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* Composer pill — full-width growing input with attach + send icons */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
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()}
|
||||
disabled={busy}
|
||||
aria-label="Přiložit fakturu"
|
||||
title="Přiložit fakturu (PDF)"
|
||||
sx={{ flexShrink: 0, color: "text.secondary" }}
|
||||
>
|
||||
Přiložit
|
||||
</Button>
|
||||
<Box
|
||||
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
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={8}
|
||||
variant="standard"
|
||||
placeholder="Napište zprávu…"
|
||||
value={input}
|
||||
onChange={(e) => onInput(e.target.value)}
|
||||
@@ -72,10 +104,43 @@ export default function OdinComposer({
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
slotProps={{ input: { disableUnderline: true } }}
|
||||
sx={{ flex: 1, "& textarea": { py: 0.75, lineHeight: 1.5 } }}
|
||||
/>
|
||||
<Button onClick={onSubmit} disabled={!canSubmit}>
|
||||
Odeslat
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function OdinSidebar({
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: 184, sm: 232, md: 264 },
|
||||
width: { xs: 280, md: 264 },
|
||||
flexShrink: 0,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
||||
@@ -170,8 +170,14 @@ export default function OdinThread({
|
||||
{turns.map((t, i) => {
|
||||
const isUser = t.role === "user";
|
||||
return (
|
||||
<Box
|
||||
<MotionBox
|
||||
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={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
@@ -204,7 +210,7 @@ export default function OdinThread({
|
||||
{t.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</MotionBox>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user