mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
Move agent section to fab (#6597)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user