) => {
+ 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 (
+
+ {/* Trigger button — fades out while panel is open */}
+ {
+ 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 && (
+ 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,
+ }}
+ >
+
+
+ setIsOpen(false)}
+ backLabel={t("chat.fab.close", "Close chat")}
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts
index 76416d57c..8caadae68 100644
--- a/frontend/editor/vite.config.ts
+++ b/frontend/editor/vite.config.ts
@@ -233,6 +233,11 @@ export default defineConfig(async ({ mode }) => {
},
},
},
+ resolve: {
+ alias: {
+ "@shared": path.resolve(__dirname, "../shared"),
+ },
+ },
optimizeDeps: {
exclude: ["@embedpdf/pdfium"],
},
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 65e97d496..11071641d 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -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
diff --git a/frontend/portal/src/components/ChatFABWidget.stories.tsx b/frontend/portal/src/components/ChatFABWidget.stories.tsx
new file mode 100644
index 000000000..1d49dc8e5
--- /dev/null
+++ b/frontend/portal/src/components/ChatFABWidget.stories.tsx
@@ -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 (
+
+ {/* Header — acts as drag handle in the real implementation */}
+
+ Stirling
+
+
+
+ {/* Messages */}
+
+ {messages.map((m) => (
+
+ {m.text}
+
+ ))}
+
+
+ {/* Input */}
+
+
+ What do you want to do?
+
+
+
+ );
+}
+
+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 */
+
+ {/* FAB button */}
+
+
+ {/* Chat panel */}
+
+
+ setOpen(false)} />
+
+
+
+ );
+}
+
+type FlowStep = "idle" | "loading" | "tick" | "open";
+
+function ChatFABFullFlowDemo() {
+ const [step, setStep] = useState("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 (
+
+ {/* Step guide */}
+
+ {(["idle", "loading", "tick", "open"] as FlowStep[]).map((s) => (
+
+ {s === "idle" && "① Idle — plain icon"}
+ {s === "loading" && "② Loading — animated logo + pulse"}
+ {s === "tick" && "③ Unread result — tick badge"}
+ {s === "open" && "④ Viewed — tick cleared"}
+
+ ))}
+
+
+
+ {/* FAB button */}
+
+
+ {/* Chat panel */}
+
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Editor/ChatFAB/ChatFABWidget",
+ parameters: { layout: "fullscreen" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+export const Closed: Story = {
+ render: () => ,
+};
+
+export const Open: Story = {
+ render: () => ,
+};
+
+/** FAB closed while the agent is running — animated logo + green pulse dot. */
+export const LoadingWhileClosed: Story = {
+ render: () => ,
+};
+
+export const LoadingWhileOpen: Story = {
+ render: () => ,
+};
+
+/**
+ * Agent finished while the panel was closed.
+ * The tick badge signals an unread result; clicking the FAB clears it.
+ */
+export const UnreadResult: Story = {
+ render: () => ,
+};
+
+/**
+ * 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: () => ,
+};
diff --git a/frontend/shared/components/ChatFABButton.css b/frontend/shared/components/ChatFABButton.css
new file mode 100644
index 000000000..6b85e4c31
--- /dev/null
+++ b/frontend/shared/components/ChatFABButton.css
@@ -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;
+ }
+}
diff --git a/frontend/shared/components/ChatFABButton.stories.tsx b/frontend/shared/components/ChatFABButton.stories.tsx
new file mode 100644
index 000000000..bd7a5bcf8
--- /dev/null
+++ b/frontend/shared/components/ChatFABButton.stories.tsx
@@ -0,0 +1,37 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ChatFABButton } from "@shared/components/ChatFABButton";
+
+const meta: Meta = {
+ 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;
+
+/** 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 },
+};
diff --git a/frontend/shared/components/ChatFABButton.tsx b/frontend/shared/components/ChatFABButton.tsx
new file mode 100644
index 000000000..8c630a13d
--- /dev/null
+++ b/frontend/shared/components/ChatFABButton.tsx
@@ -0,0 +1,66 @@
+import type { ButtonHTMLAttributes } from "react";
+import "@shared/components/ChatFABButton.css";
+
+export interface ChatFABButtonProps extends ButtonHTMLAttributes {
+ /** 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 (
+
+ );
+}
diff --git a/frontend/shared/components/ChatFABWindow.css b/frontend/shared/components/ChatFABWindow.css
new file mode 100644
index 000000000..1f59d2444
--- /dev/null
+++ b/frontend/shared/components/ChatFABWindow.css
@@ -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;
+ }
+}
diff --git a/frontend/shared/components/ChatFABWindow.stories.tsx b/frontend/shared/components/ChatFABWindow.stories.tsx
new file mode 100644
index 000000000..b2063ce3d
--- /dev/null
+++ b/frontend/shared/components/ChatFABWindow.stories.tsx
@@ -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 (
+
+
+ Stirling
+
+
+ {DEMO_MESSAGES.map((m) => (
+
+ {m.text}
+
+ ))}
+
+
+
+ What do you want to do?
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Editor/ChatFAB/ChatFABWindow",
+ component: ChatFABWindow,
+ parameters: { layout: "centered" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ open: { control: "boolean" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+export const Closed: Story = {
+ args: { open: false, children: },
+};
+
+export const Open: Story = {
+ args: { open: true, children: },
+};
+
+export const Toggle: Story = {
+ render: () => {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+
+
+
+ );
+ },
+};
diff --git a/frontend/shared/components/ChatFABWindow.tsx b/frontend/shared/components/ChatFABWindow.tsx
new file mode 100644
index 000000000..c1cafed22
--- /dev/null
+++ b/frontend/shared/components/ChatFABWindow.tsx
@@ -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;
+}
+
+/**
+ * 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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/shared/vite-env.d.ts b/frontend/shared/vite-env.d.ts
new file mode 100644
index 000000000..ef6d741f6
--- /dev/null
+++ b/frontend/shared/vite-env.d.ts
@@ -0,0 +1 @@
+declare module "*.css" {}