mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Merge branch 'main' into SaaS
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +233,11 @@ export default defineConfig(async ({ mode }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": path.resolve(__dirname, "../shared"),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@embedpdf/pdfium"],
|
||||
},
|
||||
|
||||
@@ -143,7 +143,9 @@ export default defineConfig(
|
||||
],
|
||||
},
|
||||
},
|
||||
// Stricter rules that not all sub-folders are conformant to yet
|
||||
// Stricter rules that not all sub-folders are conformant to yet.
|
||||
// Keep this non-type-aware: `parserOptions.project`/`projectService` here OOMs
|
||||
// the lint step (builds the whole TS program); tsc covers type correctness.
|
||||
{
|
||||
files: srcGlobs,
|
||||
ignores: [
|
||||
@@ -158,15 +160,8 @@ export default defineConfig(
|
||||
"editor/src/core/types/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"editor/src/core/utils/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
},
|
||||
},
|
||||
// Config for browser scripts
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css" {}
|
||||
Reference in New Issue
Block a user