Move agent section to fab (#6597)

This commit is contained in:
EthanHealy01
2026-06-10 15:47:47 +01:00
committed by GitHub
parent da4b84962c
commit 9b877d4f8d
15 changed files with 1149 additions and 0 deletions
@@ -0,0 +1,144 @@
.chat-fab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
border: none;
background: var(--color-blue, #3b82f6);
color: #fff;
cursor: pointer;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.4);
transition:
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
position: relative;
outline-offset: 3px;
flex-shrink: 0;
}
.chat-fab-btn:hover {
transform: scale(1.09);
box-shadow: 0 6px 22px rgba(59, 130, 246, 0.52);
}
.chat-fab-btn:active {
transform: scale(0.96);
transition-duration: 90ms;
}
/* Animated logo paths when the agent is actively working */
.chat-fab-btn--loading svg path:first-child {
animation: chat-fab-logo-right 2s ease-in-out infinite;
}
.chat-fab-btn--loading svg path:last-child {
animation: chat-fab-logo-left 2s ease-in-out infinite;
}
@keyframes chat-fab-logo-right {
0%,
100% {
transform: translate(0, 0);
opacity: 0.5;
}
25% {
transform: translate(-1px, -5px);
opacity: 0.45;
}
50% {
transform: translate(-6px, 0);
opacity: 0.9;
}
75% {
transform: translate(-1px, 5px);
opacity: 0.55;
}
}
@keyframes chat-fab-logo-left {
0%,
100% {
transform: translate(0, 0);
opacity: 1;
}
25% {
transform: translate(1px, 5px);
opacity: 0.85;
}
50% {
transform: translate(6px, 0);
opacity: 0.5;
}
75% {
transform: translate(1px, -5px);
opacity: 0.85;
}
}
/* Green pulse dot when the agent is actively working */
.chat-fab-btn__pulse {
position: absolute;
top: 9px;
right: 9px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
animation: chat-fab-pulse 1.5s ease-in-out infinite;
}
@keyframes chat-fab-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.35);
opacity: 0.65;
}
}
/* Tick badge shown when there's an unread result */
.chat-fab-btn__tick {
position: absolute;
top: 9px;
right: 9px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #22c55e;
display: flex;
align-items: center;
justify-content: center;
animation: chat-fab-tick-in 220ms cubic-bezier(0.32, 0.72, 0, 1) both;
}
@keyframes chat-fab-tick-in {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.chat-fab-btn {
transition: none;
}
.chat-fab-btn__pulse {
animation: none;
}
.chat-fab-btn--loading svg path:first-child,
.chat-fab-btn--loading svg path:last-child {
animation: none;
}
.chat-fab-btn__tick {
animation: none;
}
}
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChatFABButton } from "@shared/components/ChatFABButton";
const meta: Meta<typeof ChatFABButton> = {
title: "Editor/ChatFAB/ChatFABButton",
component: ChatFABButton,
parameters: { layout: "centered" },
argTypes: {
isLoading: { control: "boolean" },
showTick: { control: "boolean" },
onClick: { action: "clicked" },
},
};
export default meta;
type Story = StoryObj<typeof ChatFABButton>;
/** Default idle state — no agent running, no unread result. */
export const Default: Story = {};
/** Agent is actively working — the logo paths animate and a green pulse dot appears. */
export const Loading: Story = {
args: { isLoading: true },
};
/** Agent finished while the panel was closed — tick badge pops in to signal an unread result. */
export const Tick: Story = {
args: { showTick: true },
};
/**
* If loading restarts while a tick is already showing, the pulse dot is
* suppressed in favour of the tick until the user opens the panel.
* In practice this transition is handled by the parent via `hasUnviewedResult`.
*/
export const TickWhileLoading: Story = {
args: { isLoading: true, showTick: true },
};
@@ -0,0 +1,66 @@
import type { ButtonHTMLAttributes } from "react";
import "@shared/components/ChatFABButton.css";
export interface ChatFABButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Shows a green pulse dot to indicate the agent is actively working. */
isLoading?: boolean;
/** Shows a green tick badge to indicate an unread result is waiting. */
showTick?: boolean;
}
export function ChatFABButton({
isLoading = false,
showTick = false,
className,
...rest
}: ChatFABButtonProps) {
const classes = [
"chat-fab-btn",
isLoading ? "chat-fab-btn--loading" : "",
showTick ? "chat-fab-btn--tick" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<button type="button" className={classes} {...rest}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={28}
height={28}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.7"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
{isLoading && !showTick && (
<span className="chat-fab-btn__pulse" aria-hidden="true" />
)}
{showTick && (
<span className="chat-fab-btn__tick" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
width={10}
height={10}
viewBox="0 0 10 10"
fill="none"
>
<path
d="M2 5l2 2 4-4"
stroke="#fff"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</button>
);
}
@@ -0,0 +1,43 @@
.chat-fab-window {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
/* Use Mantine variables in the editor; fall back to shared tokens in storybook */
background: var(--mantine-color-body, var(--color-surface, #ffffff));
border-radius: 16px;
border: 1px solid var(--border-subtle, var(--color-border, #e3e8ee));
box-shadow:
0 8px 32px rgba(15, 23, 42, 0.14),
0 2px 8px rgba(15, 23, 42, 0.06);
overflow: hidden;
/* Closed state: collapsed toward bottom-right origin */
opacity: 0;
transform: scale(0.9) translateY(14px);
pointer-events: none;
transform-origin: bottom right;
transition:
opacity 200ms ease,
transform 220ms cubic-bezier(0.32, 0.72, 0, 1);
}
.chat-fab-window--open {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
[data-mantine-color-scheme="dark"] .chat-fab-window,
[data-theme="dark"] .chat-fab-window {
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.38),
0 2px 8px rgba(0, 0, 0, 0.22);
}
@media (prefers-reduced-motion: reduce) {
.chat-fab-window {
transition: opacity 100ms ease;
}
}
@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
const DEMO_MESSAGES = [
{
id: 1,
role: "user" as const,
text: "Merge the three contracts and redact all SSNs",
},
{
id: 2,
role: "assistant" as const,
text: "I'll merge the PDFs first, then run redaction on the combined document. Starting now…",
},
{ id: 3, role: "user" as const, text: "Great, also add a watermark" },
];
function MockChat() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
fontFamily: "system-ui, sans-serif",
}}
>
<div
style={{
padding: "14px 16px 10px",
borderBottom: "1px solid var(--color-border, #e3e8ee)",
fontSize: 14,
fontWeight: 600,
}}
>
Stirling
</div>
<div
style={{
flex: 1,
overflowY: "auto",
padding: "12px 16px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{DEMO_MESSAGES.map((m) => (
<div
key={m.id}
style={{
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
maxWidth: "80%",
background:
m.role === "user"
? "#3b82f6"
: "var(--color-bg-muted, #f3f4f6)",
color: m.role === "user" ? "#fff" : "inherit",
borderRadius: 10,
padding: "8px 12px",
fontSize: 13,
}}
>
{m.text}
</div>
))}
</div>
<div
style={{
padding: "10px 12px 14px",
borderTop: "1px solid var(--color-border, #e3e8ee)",
}}
>
<div
style={{
background: "var(--color-bg-muted, #f3f4f6)",
borderRadius: 10,
padding: "8px 12px",
fontSize: 13,
color: "var(--color-text-4, #64748b)",
}}
>
What do you want to do?
</div>
</div>
</div>
);
}
const meta: Meta<typeof ChatFABWindow> = {
title: "Editor/ChatFAB/ChatFABWindow",
component: ChatFABWindow,
parameters: { layout: "centered" },
decorators: [
(S) => (
<div style={{ width: 390, height: 520 }}>
<S />
</div>
),
],
argTypes: {
open: { control: "boolean" },
},
};
export default meta;
type Story = StoryObj<typeof ChatFABWindow>;
export const Closed: Story = {
args: { open: false, children: <MockChat /> },
};
export const Open: Story = {
args: { open: true, children: <MockChat /> },
};
export const Toggle: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<div style={{ width: 390, height: 520, position: "relative" }}>
<ChatFABWindow open={open}>
<MockChat />
</ChatFABWindow>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
position: "absolute",
bottom: -48,
right: 0,
background: "#3b82f6",
color: "#fff",
border: "none",
borderRadius: 8,
padding: "8px 16px",
cursor: "pointer",
}}
>
{open ? "Close panel" : "Open panel"}
</button>
</div>
);
},
};
@@ -0,0 +1,45 @@
import type { CSSProperties, MouseEventHandler, ReactNode } from "react";
import "@shared/components/ChatFABWindow.css";
export interface ChatFABWindowProps {
/** Whether the window is in its expanded/visible state. Controls the CSS transition. */
open: boolean;
children: ReactNode;
className?: string;
style?: CSSProperties;
onDoubleClick?: MouseEventHandler<HTMLDivElement>;
}
/**
* Visual frame for the floating chat panel.
*
* Renders a card shell (rounded corners, shadow, border) that animates in and
* out via CSS transitions. The caller controls open/closed state; this component
* owns only the appearance and animation — no drag logic, no context.
*/
export function ChatFABWindow({
open,
children,
className,
style,
onDoubleClick,
}: ChatFABWindowProps) {
const classes = [
"chat-fab-window",
open ? "chat-fab-window--open" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<div
className={classes}
style={style}
aria-hidden={!open}
onDoubleClick={onDoubleClick}
>
{children}
</div>
);
}