feat(odin): chat animations + immersive fullscreen mobile view
- bubbles spring in anchored at their avatar corner; thinking state is a typing bubble (three bouncing dots) that springs in/out where the reply lands; reduced-motion falls back to opacity fades - mobile /odin is immersive: AppShell hides its header and main padding below md, the chat owns 100svh full-bleed with safe-area insets and a back arrow (history back, home on deep links) - desktop card border kept divider-colored via longhand border props (a responsive border shorthand reset the color to black) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useReducedMotion,
|
||||
type Variants,
|
||||
} from "framer-motion";
|
||||
import type { ChatTurn } from "./types";
|
||||
import OdinMark from "./OdinMark";
|
||||
|
||||
@@ -150,12 +155,17 @@ export default function OdinThread({
|
||||
return (
|
||||
<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],
|
||||
}}
|
||||
initial={
|
||||
reduce
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.3 }
|
||||
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||
}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
@@ -163,6 +173,9 @@ export default function OdinThread({
|
||||
gap: 1,
|
||||
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||
maxWidth: "80%",
|
||||
// The pop grows out of the corner where the bubble is anchored
|
||||
// (next to the avatar / the composer side), not from its centre.
|
||||
transformOrigin: isUser ? "bottom right" : "bottom left",
|
||||
}}
|
||||
>
|
||||
{!isUser && <OdinMark size={28} />}
|
||||
@@ -218,25 +231,79 @@ export default function OdinThread({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Busy indicator — same 28px mark and zero row padding as the bubble
|
||||
rows, so the thinking mark sits exactly in the avatar column. */}
|
||||
{busy && (
|
||||
<Box
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
py: 1,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<OdinMark size={28} state="thinking" />
|
||||
<Typography variant="caption" sx={{ color: "inherit" }}>
|
||||
Pracuji…
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
|
||||
the avatar column, springing in/out where the reply will land. */}
|
||||
<AnimatePresence>
|
||||
{busy && (
|
||||
<MotionBox
|
||||
key="busy"
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
|
||||
}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={
|
||||
reduce
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
opacity: 0,
|
||||
scale: 0.85,
|
||||
transition: { duration: 0.16, ease: "easeIn" },
|
||||
}
|
||||
}
|
||||
transition={
|
||||
reduce
|
||||
? { duration: 0.3 }
|
||||
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
|
||||
}
|
||||
sx={{
|
||||
alignSelf: "flex-start",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: 1,
|
||||
transformOrigin: "bottom left",
|
||||
}}
|
||||
>
|
||||
<OdinMark size={28} state="thinking" />
|
||||
<Box
|
||||
role="status"
|
||||
aria-label="Odin pracuje"
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
boxShadow: 1,
|
||||
bgcolor: "background.paper",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.6,
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<MotionBox
|
||||
key={i}
|
||||
animate={
|
||||
reduce
|
||||
? { opacity: [0.35, 1, 0.35] }
|
||||
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.9,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
bgcolor: "text.secondary",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</MotionBox>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user