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
@@ -2736,6 +2736,10 @@ unsupported_capability = "Unsupported capability: {{capability}}"
summary = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[chat.fab]
close = "Close chat"
open = "Open Stirling AI assistant"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
@@ -0,0 +1,8 @@
/**
* Core stub for the floating action button chat widget.
* The real implementation lives in proprietary/components/chat/ChatFAB.tsx
* and shadows this via the @app/* alias cascade in proprietary builds.
*/
export function ChatFAB() {
return null;
}
@@ -17,6 +17,7 @@ import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
import { ChatFAB } from "@app/components/chat/ChatFAB";
// Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the
// full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file
@@ -225,6 +226,9 @@ export default function Workbench() {
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Floating AI chat button + panel */}
<ChatFAB />
{/* Main content area */}
<Box
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
@@ -8,6 +8,10 @@ export const Z_ANALYTICS_MODAL = 1301;
export const Z_INDEX_CONFIG_MODAL = 1400;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
// Chat FAB overlay — sits above normal app chrome (fullscreen surface: 1000)
// but below all modals (automate: 1100, file manager: 1200, config: 1400).
export const Z_INDEX_CHAT_FAB_OVERLAY = 1050;
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
export const Z_INDEX_AUTOMATE_MODAL = 1100;
@@ -0,0 +1,60 @@
/* Overlay layer ─────────────────────────────────────────────────────────── */
.chat-fab-overlay {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
/* FAB trigger button ─────────────────────────────────────────────────────── */
.chat-fab-trigger {
position: absolute;
right: 16px;
bottom: calc(var(--footer-height, 2rem) + 16px);
pointer-events: auto;
opacity: 1;
/* Include box-shadow so the ChatFABButton hover shadow still animates */
transition:
opacity 160ms ease,
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
.chat-fab-trigger {
transition: none;
}
}
.chat-fab-trigger--hidden {
opacity: 0;
transform: scale(0.78);
pointer-events: none;
}
/* Panel layout ───────────────────────────────────────────────────────────── */
/* ChatPanel fills remaining height when inside the FAB window */
.chat-fab-panel-rnd .chat-panel--embedded {
flex: 1;
height: auto;
min-height: 0;
}
/* Header is the drag handle — grab cursor on background areas */
.chat-fab-panel-rnd .chat-panel__header {
cursor: grab;
user-select: none;
}
.chat-fab-panel-rnd .chat-panel__header:active {
cursor: grabbing;
}
/* Buttons inside the header keep their pointer cursor */
.chat-fab-panel-rnd .chat-panel__header button,
.chat-fab-panel-rnd .chat-panel__header [role="button"] {
cursor: pointer;
}
@@ -0,0 +1,198 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createTheme, MantineProvider, Popover } from "@mantine/core";
import { Rnd } from "react-rnd";
import { useTranslation } from "react-i18next";
import { ChatFABButton } from "@shared/components/ChatFABButton";
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { useChat } from "@app/components/chat/ChatContext";
import { useAgentsEnabled } from "@app/components/agents/AgentsPanel";
import { Z_INDEX_CHAT_FAB_OVERLAY } from "@app/styles/zIndex";
import "@app/components/chat/ChatFAB.css";
const PANEL_WIDTH_PX = 390;
const PANEL_HEIGHT_PX = 520;
const PANEL_MIN_WIDTH_PX = 300;
const PANEL_MIN_HEIGHT_PX = 380;
const FAB_GAP_PX = 16;
// footer-height (2rem = 32px) + gap so the panel clears the footer
const FAB_BOTTOM_OFFSET_PX = 32 + FAB_GAP_PX;
const RESET_MS = 380;
const RESET_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
const RESET_TRANSITION = `transform ${RESET_MS}ms ${RESET_EASING}, width ${RESET_MS}ms ${RESET_EASING}, height ${RESET_MS}ms ${RESET_EASING}`;
// Raise Mantine popup z-index so Menu/Popover portals appear above the FAB overlay.
const FAB_PANEL_THEME = createTheme({
components: {
Popover: Popover.extend({
defaultProps: { zIndex: Z_INDEX_CHAT_FAB_OVERLAY + 300 },
}),
},
});
// Resize handle strips sit half-inside / half-outside the 1px border.
// zIndex: 1 ensures they appear above ChatFABWindow's stacking context
// (which is created by the CSS open/close transform), so the resize cursor
// is visible on hover — not just during active drag.
// Corners get a 14×14 zone; edges get a 6px-wide strip.
const RESIZE_HANDLES = {
top: { top: -3, left: 14, right: 14, height: 6, zIndex: 1 },
bottom: { bottom: -3, left: 14, right: 14, height: 6, zIndex: 1 },
left: { left: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
right: { right: -3, top: 14, bottom: 14, width: 6, zIndex: 1 },
topLeft: { top: -4, left: -4, width: 14, height: 14, zIndex: 1 },
topRight: { top: -4, right: -4, width: 14, height: 14, zIndex: 1 },
bottomLeft: { bottom: -4, left: -4, width: 14, height: 14, zIndex: 1 },
bottomRight: { bottom: -4, right: -4, width: 14, height: 14, zIndex: 1 },
};
export function ChatFAB() {
const { t } = useTranslation();
// Intentionally separate from useChat().isOpen — the FAB tracks its own
// open state so it doesn't interact with the right-rail chat panel.
const [isOpen, setIsOpen] = useState(false);
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
const { isLoading } = useChat();
const enabled = useAgentsEnabled();
// Detect loading → done transition. If the FAB is closed when the agent
// finishes, show the tick badge until the user opens the panel.
const isOpenRef = useRef(isOpen);
isOpenRef.current = isOpen;
// Initialize to false, not isLoading: if loading is already in-flight at
// mount we never showed the spinner, so we shouldn't show the tick on completion.
const prevIsLoadingRef = useRef(false);
useEffect(() => {
const wasLoading = prevIsLoadingRef.current;
prevIsLoadingRef.current = isLoading;
if (wasLoading && !isLoading && !isOpenRef.current) {
setHasUnviewedResult(true);
}
}, [isLoading]);
const overlayRef = useRef<HTMLDivElement>(null);
const [rndPos, setRndPos] = useState<{ x: number; y: number } | null>(null);
const [rndSize, setRndSize] = useState({
width: PANEL_WIDTH_PX,
height: PANEL_HEIGHT_PX,
});
const [isAnimatingReset, setIsAnimatingReset] = useState(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const getDefaultPos = () => {
const el = overlayRef.current;
if (!el) return null;
return {
x: el.offsetWidth - PANEL_WIDTH_PX - FAB_GAP_PX,
y: el.offsetHeight - PANEL_HEIGHT_PX - FAB_BOTTOM_OFFSET_PX,
};
};
useLayoutEffect(() => {
const pos = getDefaultPos();
if (pos) setRndPos(pos);
}, []);
// Clear the reset timer on unmount to avoid state updates on dead components.
// Also ensure body user-select is restored if we unmount mid-resize.
useEffect(() => {
return () => {
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
document.body.style.removeProperty("user-select");
document.body.style.removeProperty("-webkit-user-select");
};
}, []);
const cancelResetAnimation = () => {
if (resetTimerRef.current !== null) {
clearTimeout(resetTimerRef.current);
resetTimerRef.current = null;
}
setIsAnimatingReset(false);
};
const handleHeaderDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest(".chat-panel__header") && !target.closest("button")) {
const pos = getDefaultPos();
if (!pos) return;
if (resetTimerRef.current !== null) clearTimeout(resetTimerRef.current);
setIsAnimatingReset(true);
setRndPos(pos);
setRndSize({ width: PANEL_WIDTH_PX, height: PANEL_HEIGHT_PX });
resetTimerRef.current = setTimeout(() => {
setIsAnimatingReset(false);
resetTimerRef.current = null;
}, RESET_MS + 60);
}
};
if (!enabled) return null;
return (
<div
ref={overlayRef}
className="chat-fab-overlay"
style={{ zIndex: Z_INDEX_CHAT_FAB_OVERLAY }}
>
{/* Trigger button — fades out while panel is open */}
<ChatFABButton
className={`chat-fab-trigger${isOpen ? " chat-fab-trigger--hidden" : ""}`}
onClick={() => {
setIsOpen(true);
setHasUnviewedResult(false);
}}
aria-label={t("chat.fab.open", "Open Stirling AI assistant")}
aria-expanded={isOpen}
isLoading={isLoading}
showTick={hasUnviewedResult && !isLoading}
/>
{/* Draggable / resizable panel */}
{rndPos !== null && (
<Rnd
className="chat-fab-panel-rnd"
position={rndPos}
size={rndSize}
minWidth={PANEL_MIN_WIDTH_PX}
minHeight={PANEL_MIN_HEIGHT_PX}
bounds="parent"
enableResizing={true}
// Drag from the header; cancel keeps buttons inside it clickable
dragHandleClassName="chat-panel__header"
cancel="button, [role='button']"
onDragStart={cancelResetAnimation}
onDragStop={(_e, d) => setRndPos({ x: d.x, y: d.y })}
onResizeStart={() => {
cancelResetAnimation();
document.body.style.setProperty("user-select", "none");
document.body.style.setProperty("-webkit-user-select", "none");
}}
onResizeStop={(_e, _dir, ref, _delta, pos) => {
document.body.style.removeProperty("user-select");
document.body.style.removeProperty("-webkit-user-select");
setRndSize({ width: ref.offsetWidth, height: ref.offsetHeight });
setRndPos(pos);
}}
// Invisible strips centred on the border — cursor change is the affordance.
// zIndex: 1 lifts them above ChatFABWindow's stacking context.
resizeHandleStyles={RESIZE_HANDLES}
style={{
pointerEvents: isOpen ? "auto" : "none",
transition: isAnimatingReset ? RESET_TRANSITION : undefined,
}}
>
<ChatFABWindow open={isOpen} onDoubleClick={handleHeaderDoubleClick}>
<MantineProvider theme={FAB_PANEL_THEME}>
<ChatPanel
onBack={() => setIsOpen(false)}
backLabel={t("chat.fab.close", "Close chat")}
/>
</MantineProvider>
</ChatFABWindow>
</Rnd>
)}
</div>
);
}
+5
View File
@@ -233,6 +233,11 @@ export default defineConfig(async ({ mode }) => {
},
},
},
resolve: {
alias: {
"@shared": path.resolve(__dirname, "../shared"),
},
},
optimizeDeps: {
exclude: ["@embedpdf/pdfium"],
},
@@ -0,0 +1,385 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useRef, useState } from "react";
import { ChatFABButton } from "@shared/components/ChatFABButton";
import { ChatFABWindow } from "@shared/components/ChatFABWindow";
/**
* Full ChatFAB widget demo — composed from ChatFABButton + ChatFABWindow.
*
* This story simulates the open/close interaction as it looks in the editor
* without needing the full editor context. The real implementation wires
* these same components to ChatContext and react-rnd for dragging.
*/
interface MockMessage {
id: number;
role: "user" | "assistant";
text: string;
}
const INITIAL_MESSAGES: MockMessage[] = [
{
id: 1,
role: "user",
text: "Merge the three contracts and redact all SSNs",
},
{
id: 2,
role: "assistant",
text: "I'll merge the PDFs first, then run automatic PII redaction on the combined document.",
},
];
function MockChatContent({
messages,
onClose,
}: {
messages: MockMessage[];
onClose: () => void;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
fontFamily: "system-ui, sans-serif",
fontSize: 14,
}}
>
{/* Header — acts as drag handle in the real implementation */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 16px 10px",
borderBottom: "1px solid var(--color-border, #e3e8ee)",
flexShrink: 0,
}}
>
<span style={{ fontWeight: 600 }}>Stirling</span>
<button
type="button"
onClick={onClose}
aria-label="Close chat"
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
borderRadius: 6,
color: "var(--color-text-4, #64748b)",
}}
>
</button>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflowY: "auto",
padding: "12px 16px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{messages.map((m) => (
<div
key={m.id}
style={{
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
maxWidth: "82%",
background:
m.role === "user"
? "#3b82f6"
: "var(--color-bg-muted, #f3f4f6)",
color: m.role === "user" ? "#fff" : "inherit",
borderRadius: 10,
padding: "8px 12px",
fontSize: 13,
lineHeight: 1.5,
}}
>
{m.text}
</div>
))}
</div>
{/* Input */}
<div
style={{
padding: "10px 12px 14px",
borderTop: "1px solid var(--color-border, #e3e8ee)",
flexShrink: 0,
}}
>
<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>
);
}
function ChatFABWidgetDemo({
startOpen = false,
agentLoading = false,
showTick = false,
}: {
startOpen?: boolean;
agentLoading?: boolean;
showTick?: boolean;
}) {
const [open, setOpen] = useState(startOpen);
const [hasUnviewedResult, setHasUnviewedResult] = useState(showTick);
const messages = INITIAL_MESSAGES;
return (
/* Simulates the workbench overlay container */
<div
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "hidden",
background: "var(--color-bg, #f8f9fb)",
}}
>
{/* FAB button */}
<button
type="button"
onClick={() => {
setOpen(true);
setHasUnviewedResult(false);
}}
aria-label="Open Stirling AI assistant"
aria-expanded={open}
style={{
position: "absolute",
right: 16,
bottom: 16,
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
opacity: open ? 0 : 1,
transform: open ? "scale(0.78)" : "scale(1)",
transition:
"opacity 160ms ease, transform 180ms cubic-bezier(0.32, 0.72, 0, 1)",
pointerEvents: open ? "none" : "auto",
}}
>
<ChatFABButton
isLoading={agentLoading}
showTick={hasUnviewedResult && !agentLoading}
tabIndex={open ? -1 : 0}
/>
</button>
{/* Chat panel */}
<div
style={{
position: "absolute",
right: 16,
bottom: 16,
width: 390,
height: 520,
}}
>
<ChatFABWindow open={open}>
<MockChatContent messages={messages} onClose={() => setOpen(false)} />
</ChatFABWindow>
</div>
</div>
);
}
type FlowStep = "idle" | "loading" | "tick" | "open";
function ChatFABFullFlowDemo() {
const [step, setStep] = useState<FlowStep>("idle");
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnviewedResult, setHasUnviewedResult] = useState(false);
const isOpenRef = useRef(open);
isOpenRef.current = open;
// Simulate agent finishing while FAB is closed → show tick
const simulateAgentRun = () => {
setStep("loading");
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
if (!isOpenRef.current) setHasUnviewedResult(true);
setStep("tick");
}, 2500);
};
const handleOpen = () => {
setOpen(true);
setHasUnviewedResult(false);
setStep("open");
};
const handleClose = () => {
setOpen(false);
setStep("idle");
};
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Step guide */}
<div
style={{
position: "absolute",
top: 16,
left: 16,
zIndex: 10,
fontFamily: "system-ui, sans-serif",
fontSize: 13,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{(["idle", "loading", "tick", "open"] as FlowStep[]).map((s) => (
<div
key={s}
style={{
padding: "4px 10px",
borderRadius: 6,
background:
step === s ? "#3b82f6" : "var(--color-bg-muted, #f3f4f6)",
color: step === s ? "#fff" : "inherit",
fontWeight: step === s ? 600 : 400,
}}
>
{s === "idle" && "① Idle — plain icon"}
{s === "loading" && "② Loading — animated logo + pulse"}
{s === "tick" && "③ Unread result — tick badge"}
{s === "open" && "④ Viewed — tick cleared"}
</div>
))}
<button
type="button"
onClick={simulateAgentRun}
disabled={isLoading || open}
style={{
marginTop: 4,
padding: "6px 12px",
borderRadius: 6,
border: "none",
background: "#22c55e",
color: "#fff",
cursor: isLoading || open ? "not-allowed" : "pointer",
opacity: isLoading || open ? 0.5 : 1,
fontSize: 13,
}}
>
Simulate agent run
</button>
</div>
{/* FAB button */}
<button
type="button"
onClick={handleOpen}
aria-label="Open Stirling AI assistant"
style={{
position: "absolute",
right: 16,
bottom: 16,
padding: 0,
border: "none",
background: "none",
cursor: "pointer",
opacity: open ? 0 : 1,
transform: open ? "scale(0.78)" : "scale(1)",
transition:
"opacity 160ms ease, transform 180ms cubic-bezier(0.32, 0.72, 0, 1)",
pointerEvents: open ? "none" : "auto",
}}
>
<ChatFABButton
isLoading={isLoading}
showTick={hasUnviewedResult && !isLoading}
/>
</button>
{/* Chat panel */}
<div
style={{
position: "absolute",
right: 16,
bottom: 16,
width: 390,
height: 520,
}}
>
<ChatFABWindow open={open}>
<MockChatContent messages={INITIAL_MESSAGES} onClose={handleClose} />
</ChatFABWindow>
</div>
</div>
);
}
const meta: Meta = {
title: "Editor/ChatFAB/ChatFABWidget",
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ minHeight: "100vh", position: "relative" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj;
export const Closed: Story = {
render: () => <ChatFABWidgetDemo startOpen={false} />,
};
export const Open: Story = {
render: () => <ChatFABWidgetDemo startOpen={true} />,
};
/** FAB closed while the agent is running — animated logo + green pulse dot. */
export const LoadingWhileClosed: Story = {
render: () => <ChatFABWidgetDemo startOpen={false} agentLoading={true} />,
};
export const LoadingWhileOpen: Story = {
render: () => <ChatFABWidgetDemo startOpen={true} agentLoading={true} />,
};
/**
* Agent finished while the panel was closed.
* The tick badge signals an unread result; clicking the FAB clears it.
*/
export const UnreadResult: Story = {
render: () => <ChatFABWidgetDemo startOpen={false} showTick={true} />,
};
/**
* Interactive walkthrough of the full notification state machine:
* idle → loading (animated logo) → tick badge (unread) → open (tick clears) → idle.
* Use the "Simulate agent run" button to trigger the transition.
*/
export const FullFlow: Story = {
render: () => <ChatFABFullFlowDemo />,
};
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
declare module "*.css" {}