From 7cefff59fd3ed7cd3fbd4f7b15df860a50908c69 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 14:09:23 +0000 Subject: [PATCH 01/43] feat(webapp): draggable/resizable window primitive useDraggableResizable hook (framer-motion pan gestures, pure geometry helpers) for a floating window draggable and resizable from any edge, clamped to the viewport. Adds a storybook demo route. --- .../primitives/DraggableResizable.tsx | 156 ++++++++++++++++++ .../primitives/draggableResizableMath.test.ts | 125 ++++++++++++++ .../primitives/draggableResizableMath.ts | 75 +++++++++ .../storybook.draggable-resizable/route.tsx | 46 ++++++ apps/webapp/app/routes/storybook/route.tsx | 1 + apps/webapp/vitest.config.ts | 1 + 6 files changed, 404 insertions(+) create mode 100644 apps/webapp/app/components/primitives/DraggableResizable.tsx create mode 100644 apps/webapp/app/components/primitives/draggableResizableMath.test.ts create mode 100644 apps/webapp/app/components/primitives/draggableResizableMath.ts create mode 100644 apps/webapp/app/routes/storybook.draggable-resizable/route.tsx diff --git a/apps/webapp/app/components/primitives/DraggableResizable.tsx b/apps/webapp/app/components/primitives/DraggableResizable.tsx new file mode 100644 index 00000000000..c864422251f --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { type PanInfo } from "framer-motion"; +import { cn } from "~/utils/cn"; +import { + clampPosition, + clampRectToViewport, + clampSize, + resizeRect, + type Point, + type Rect, + type ResizeEdge, + type Size, +} from "./draggableResizableMath"; + +export type { ResizeEdge } from "./draggableResizableMath"; + +export type UseDraggableResizableOptions = { + initial: Rect; + minSize: Size; + maxSize?: Size; + /** Minimum distance kept from the viewport edges. Defaults to 8px. */ + viewportPadding?: number; +}; + +/** Spread onto a framer-motion `motion.div` — drag/resize tracking rides on its pan gesture. */ +export type PanHandlerProps = { + onPanStart: (event: PointerEvent, info: PanInfo) => void; + onPan: (event: PointerEvent, info: PanInfo) => void; + onPanEnd: (event: PointerEvent, info: PanInfo) => void; +}; + +export type UseDraggableResizableResult = { + /** + * position:fixed with left/top/width/height set from state. `x`/`y` are the + * top-left corner in viewport coordinates — if the window docks bottom-right, + * derive the initial x/y from `window.innerWidth/innerHeight - w/h - padding`. + */ + style: CSSProperties; + dragHandleProps: PanHandlerProps; + resizeHandleProps: (edge: ResizeEdge) => PanHandlerProps; + position: Point; + size: Size; +}; + +function getViewport() { + return { width: window.innerWidth, height: window.innerHeight }; +} + +export function useDraggableResizable({ + initial, + minSize, + maxSize, + viewportPadding = 8, +}: UseDraggableResizableOptions): UseDraggableResizableResult { + const [rect, setRect] = useState(() => { + const size = clampSize({ w: initial.w, h: initial.h }, minSize, maxSize); + return { ...clampPosition(initial, size, getViewport(), viewportPadding), ...size }; + }); + const rectRef = useRef(rect); + // oxlint-disable-next-line react/refs -- mirrors state into a ref for use inside gesture callbacks, not for rendering. + rectRef.current = rect; + + // Snapshot of the rect at gesture start; framer-motion's PanInfo.offset is + // cumulative from pan start, so every onPan step re-applies it to this. + const startRectRef = useRef(rect); + + // Re-clamp on viewport resize so the box never strands off-screen. + useEffect(() => { + const onResize = () => { + setRect((current) => clampRectToViewport(current, getViewport(), viewportPadding)); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [viewportPadding]); + + const dragHandleProps: PanHandlerProps = { + onPanStart: useCallback(() => { + startRectRef.current = rectRef.current; + }, []), + onPan: useCallback( + (_event, info: PanInfo) => { + const startRect = startRectRef.current; + const nextPosition = clampPosition( + { x: startRect.x + info.offset.x, y: startRect.y + info.offset.y }, + { w: startRect.w, h: startRect.h }, + getViewport(), + viewportPadding + ); + setRect((current) => ({ ...current, ...nextPosition })); + }, + [viewportPadding] + ), + onPanEnd: useCallback(() => {}, []), + }; + + const resizeHandleProps = useCallback( + (edge: ResizeEdge): PanHandlerProps => ({ + onPanStart: () => { + startRectRef.current = rectRef.current; + }, + onPan: (_event, info: PanInfo) => { + const startRect = startRectRef.current; + const resized = resizeRect(edge, startRect, info.offset.x, info.offset.y, minSize, maxSize); + setRect(clampRectToViewport(resized, getViewport(), viewportPadding)); + }, + onPanEnd: () => {}, + }), + [minSize, maxSize, viewportPadding] + ); + + return { + style: { + position: "fixed", + left: rect.x, + top: rect.y, + width: rect.w, + height: rect.h, + }, + dragHandleProps, + resizeHandleProps, + position: { x: rect.x, y: rect.y }, + size: { w: rect.w, h: rect.h }, + }; +} + +const EDGE_CURSOR: Record = { + n: "cursor-ns-resize", + s: "cursor-ns-resize", + e: "cursor-ew-resize", + w: "cursor-ew-resize", + ne: "cursor-nesw-resize", + sw: "cursor-nesw-resize", + nw: "cursor-nwse-resize", + se: "cursor-nwse-resize", +}; + +const EDGE_POSITION: Record = { + n: "inset-x-0 top-0 h-1.5 -translate-y-1/2", + s: "inset-x-0 bottom-0 h-1.5 translate-y-1/2", + e: "inset-y-0 right-0 w-1.5 translate-x-1/2", + w: "inset-y-0 left-0 w-1.5 -translate-x-1/2", + ne: "right-0 top-0 h-3 w-3 translate-x-1/2 -translate-y-1/2", + nw: "left-0 top-0 h-3 w-3 -translate-x-1/2 -translate-y-1/2", + se: "right-0 bottom-0 h-3 w-3 translate-x-1/2 translate-y-1/2", + sw: "left-0 bottom-0 h-3 w-3 -translate-x-1/2 translate-y-1/2", +}; + +/** Thin hit area for one resize edge/corner, styled to match ResizableHandle. Spread `resizeHandleProps(edge)` onto it. */ +export function DraggableResizeHandleClassName(edge: ResizeEdge, className?: string) { + return cn( + "absolute z-10 touch-none select-none", + EDGE_CURSOR[edge], + EDGE_POSITION[edge], + className + ); +} diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts new file mode 100644 index 00000000000..030ae1209ca --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + clamp, + clampPosition, + clampRectToViewport, + clampSize, + resizeRect, +} from "./draggableResizableMath"; + +describe("clamp", () => { + it("clamps to the bounds", () => { + expect(clamp(5, 0, 10)).toBe(5); + expect(clamp(-5, 0, 10)).toBe(0); + expect(clamp(15, 0, 10)).toBe(10); + }); +}); + +describe("clampSize", () => { + it("enforces the min size", () => { + expect(clampSize({ w: 10, h: 10 }, { w: 100, h: 50 })).toEqual({ w: 100, h: 50 }); + }); + + it("enforces the max size when given", () => { + expect(clampSize({ w: 1000, h: 1000 }, { w: 100, h: 50 }, { w: 400, h: 300 })).toEqual({ + w: 400, + h: 300, + }); + }); + + it("is a no-op within bounds", () => { + expect(clampSize({ w: 200, h: 150 }, { w: 100, h: 50 }, { w: 400, h: 300 })).toEqual({ + w: 200, + h: 150, + }); + }); +}); + +describe("clampPosition", () => { + const viewport = { width: 1000, height: 800 }; + + it("keeps a rect fully within the padded viewport", () => { + expect(clampPosition({ x: -50, y: -50 }, { w: 300, h: 200 }, viewport, 10)).toEqual({ + x: 10, + y: 10, + }); + expect(clampPosition({ x: 5000, y: 5000 }, { w: 300, h: 200 }, viewport, 10)).toEqual({ + x: 690, + y: 590, + }); + }); + + it("is a no-op when already inside bounds", () => { + expect(clampPosition({ x: 100, y: 100 }, { w: 300, h: 200 }, viewport, 10)).toEqual({ + x: 100, + y: 100, + }); + }); + + it("falls back to padding when the box is larger than the viewport", () => { + expect(clampPosition({ x: 100, y: 100 }, { w: 2000, h: 2000 }, viewport, 10)).toEqual({ + x: 10, + y: 10, + }); + }); +}); + +describe("clampRectToViewport", () => { + it("clamps position while leaving size untouched", () => { + expect( + clampRectToViewport({ x: -100, y: 50, w: 300, h: 200 }, { width: 1000, height: 800 }, 10) + ).toEqual({ x: 10, y: 50, w: 300, h: 200 }); + }); +}); + +describe("resizeRect", () => { + const start = { x: 100, y: 100, w: 300, h: 200 }; + const minSize = { w: 100, h: 80 }; + + it("east edge grows width, keeps x/y", () => { + expect(resizeRect("e", start, 50, 0, minSize)).toEqual({ x: 100, y: 100, w: 350, h: 200 }); + }); + + it("south edge grows height, keeps x/y", () => { + expect(resizeRect("s", start, 0, 40, minSize)).toEqual({ x: 100, y: 100, w: 300, h: 240 }); + }); + + it("west edge shrinks width and moves x to keep the right edge fixed", () => { + expect(resizeRect("w", start, 50, 0, minSize)).toEqual({ x: 150, y: 100, w: 250, h: 200 }); + }); + + it("north edge shrinks height and moves y to keep the bottom edge fixed", () => { + expect(resizeRect("n", start, 0, 30, minSize)).toEqual({ x: 100, y: 130, w: 300, h: 170 }); + }); + + it("corner edges combine both axes", () => { + expect(resizeRect("nw", start, 20, 20, minSize)).toEqual({ + x: 120, + y: 120, + w: 280, + h: 180, + }); + expect(resizeRect("se", start, -20, -20, minSize)).toEqual({ + x: 100, + y: 100, + w: 280, + h: 180, + }); + }); + + it("respects min size when shrinking past it", () => { + expect(resizeRect("e", start, -1000, 0, minSize)).toEqual({ x: 100, y: 100, w: 100, h: 200 }); + // west edge: width clamps to min, x stops moving with it + expect(resizeRect("w", start, 1000, 0, minSize)).toEqual({ x: 300, y: 100, w: 100, h: 200 }); + }); + + it("respects max size when growing past it", () => { + const maxSize = { w: 400, h: 300 }; + expect(resizeRect("se", start, 1000, 1000, minSize, maxSize)).toEqual({ + x: 100, + y: 100, + w: 400, + h: 300, + }); + }); +}); diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.ts b/apps/webapp/app/components/primitives/draggableResizableMath.ts new file mode 100644 index 00000000000..f46bf6b5872 --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -0,0 +1,75 @@ +// Pure geometry helpers for useDraggableResizable. No DOM/React here so they're easy to unit test. + +export type Point = { x: number; y: number }; +export type Size = { w: number; h: number }; +export type Rect = Point & Size; +export type ResizeEdge = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"; +export type Viewport = { width: number; height: number }; + +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function clampSize(size: Size, minSize: Size, maxSize?: Size): Size { + return { + w: clamp(size.w, minSize.w, maxSize?.w ?? Infinity), + h: clamp(size.h, minSize.h, maxSize?.h ?? Infinity), + }; +} + +/** Keeps the rect's top-left within [padding, viewport - padding - size], shrinking padding if the viewport is too small to honor it. */ +export function clampPosition( + position: Point, + size: Size, + viewport: Viewport, + padding: number +): Point { + const maxX = Math.max(padding, viewport.width - padding - size.w); + const maxY = Math.max(padding, viewport.height - padding - size.h); + return { + x: clamp(position.x, padding, maxX), + y: clamp(position.y, padding, maxY), + }; +} + +export function clampRectToViewport(rect: Rect, viewport: Viewport, padding: number): Rect { + const position = clampPosition( + { x: rect.x, y: rect.y }, + { w: rect.w, h: rect.h }, + viewport, + padding + ); + return { ...position, w: rect.w, h: rect.h }; +} + +/** + * Applies a pointer delta to `start` for the given resize edge, respecting min/max size. + * North/west edges move the opposite corner too so the far edge stays put. + */ +export function resizeRect( + edge: ResizeEdge, + start: Rect, + dx: number, + dy: number, + minSize: Size, + maxSize?: Size +): Rect { + let { x, y, w, h } = start; + + if (edge.includes("e")) { + w = clamp(start.w + dx, minSize.w, maxSize?.w ?? Infinity); + } + if (edge.includes("s")) { + h = clamp(start.h + dy, minSize.h, maxSize?.h ?? Infinity); + } + if (edge.includes("w")) { + w = clamp(start.w - dx, minSize.w, maxSize?.w ?? Infinity); + x = start.x + (start.w - w); + } + if (edge.includes("n")) { + h = clamp(start.h - dy, minSize.h, maxSize?.h ?? Infinity); + y = start.y + (start.h - h); + } + + return { x, y, w, h }; +} diff --git a/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx b/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx new file mode 100644 index 00000000000..2fc7cad4da5 --- /dev/null +++ b/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx @@ -0,0 +1,46 @@ +import { motion } from "framer-motion"; +import { ComponentNames } from "../storybook/StoryKit"; +import { + DraggableResizeHandleClassName, + useDraggableResizable, + type ResizeEdge, +} from "~/components/primitives/DraggableResizable"; + +const EDGES: ResizeEdge[] = ["n", "e", "s", "w", "ne", "nw", "se", "sw"]; + +export default function Story() { + const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({ + initial: { x: 120, y: 120, w: 360, h: 240 }, + minSize: { w: 200, h: 140 }, + maxSize: { w: 640, h: 480 }, + }); + + return ( +
+
+ +
+
+ + Drag me + +
+ Resize from any edge or corner +
+ {EDGES.map((edge) => ( + + ))} +
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 35f51682938..e76ba520236 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -95,6 +95,7 @@ const sections: StorySection[] = [ { name: "Page header", slug: "page-header" }, { name: "Tree view", slug: "tree-view" }, { name: "Resizable", slug: "resizable" }, + { name: "Draggable resizable", slug: "draggable-resizable" }, { name: "Animated panel", slug: "animated-panel" }, { name: "Accordion", slug: "accordion" }, ], diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index dabe517bf4f..3ccfd214c8f 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "app/components/runs/**/*.test.ts", "app/components/dashboard-agent/**/*.test.ts", "app/components/queues/**/*.test.ts", + "app/components/primitives/**/*.test.ts", "app/routes/storybook.agent-ui/*.test.ts", "app/presenters/v3/reports/**/*.test.ts", ], From c46f8b90f14006721eec340ac1a1be98868e4095 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 14:21:05 +0000 Subject: [PATCH 02/43] fix(webapp): SSR-safe viewport read and viewport-aware resize clamping getViewport() no longer reads window during SSR; the resize effect re-clamps once on mount. resizeRect now caps per-edge growth against the viewport before deriving x/y, so the far edge never drifts. Drops gratuitous useCallback in handler literals, renames the className helper to reflect it returns a string. --- .../primitives/DraggableResizable.tsx | 79 +++++++++-------- .../primitives/draggableResizableMath.test.ts | 86 +++++++++++++++++-- .../primitives/draggableResizableMath.ts | 22 +++-- .../storybook.draggable-resizable/route.tsx | 4 +- 4 files changed, 139 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/components/primitives/DraggableResizable.tsx b/apps/webapp/app/components/primitives/DraggableResizable.tsx index c864422251f..33cb19c0f76 100644 --- a/apps/webapp/app/components/primitives/DraggableResizable.tsx +++ b/apps/webapp/app/components/primitives/DraggableResizable.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import { type PanInfo } from "framer-motion"; import { cn } from "~/utils/cn"; import { @@ -10,6 +10,7 @@ import { type Rect, type ResizeEdge, type Size, + type Viewport, } from "./draggableResizableMath"; export type { ResizeEdge } from "./draggableResizableMath"; @@ -42,7 +43,12 @@ export type UseDraggableResizableResult = { size: Size; }; -function getViewport() { +function getViewport(): Viewport { + // SSR: no window. Report an unbounded viewport so the initial clamp is a no-op; + // the mount-time effect below re-clamps against the real viewport once hydrated. + if (typeof window === "undefined") { + return { width: Infinity, height: Infinity }; + } return { width: window.innerWidth, height: window.innerHeight }; } @@ -64,49 +70,54 @@ export function useDraggableResizable({ // cumulative from pan start, so every onPan step re-applies it to this. const startRectRef = useRef(rect); - // Re-clamp on viewport resize so the box never strands off-screen. + // Re-clamp on viewport resize (and once on mount, since SSR renders against + // an unbounded viewport) so the box never strands off-screen. useEffect(() => { const onResize = () => { setRect((current) => clampRectToViewport(current, getViewport(), viewportPadding)); }; + onResize(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [viewportPadding]); const dragHandleProps: PanHandlerProps = { - onPanStart: useCallback(() => { + onPanStart: () => { startRectRef.current = rectRef.current; - }, []), - onPan: useCallback( - (_event, info: PanInfo) => { - const startRect = startRectRef.current; - const nextPosition = clampPosition( - { x: startRect.x + info.offset.x, y: startRect.y + info.offset.y }, - { w: startRect.w, h: startRect.h }, - getViewport(), - viewportPadding - ); - setRect((current) => ({ ...current, ...nextPosition })); - }, - [viewportPadding] - ), - onPanEnd: useCallback(() => {}, []), + }, + onPan: (_event, info: PanInfo) => { + const startRect = startRectRef.current; + const nextPosition = clampPosition( + { x: startRect.x + info.offset.x, y: startRect.y + info.offset.y }, + { w: startRect.w, h: startRect.h }, + getViewport(), + viewportPadding + ); + setRect((current) => ({ ...current, ...nextPosition })); + }, + onPanEnd: () => {}, }; - const resizeHandleProps = useCallback( - (edge: ResizeEdge): PanHandlerProps => ({ - onPanStart: () => { - startRectRef.current = rectRef.current; - }, - onPan: (_event, info: PanInfo) => { - const startRect = startRectRef.current; - const resized = resizeRect(edge, startRect, info.offset.x, info.offset.y, minSize, maxSize); - setRect(clampRectToViewport(resized, getViewport(), viewportPadding)); - }, - onPanEnd: () => {}, - }), - [minSize, maxSize, viewportPadding] - ); + const resizeHandleProps = (edge: ResizeEdge): PanHandlerProps => ({ + onPanStart: () => { + startRectRef.current = rectRef.current; + }, + onPan: (_event, info: PanInfo) => { + const startRect = startRectRef.current; + const resized = resizeRect( + edge, + startRect, + info.offset.x, + info.offset.y, + minSize, + maxSize, + getViewport(), + viewportPadding + ); + setRect(clampRectToViewport(resized, getViewport(), viewportPadding)); + }, + onPanEnd: () => {}, + }); return { style: { @@ -146,7 +157,7 @@ const EDGE_POSITION: Record = { }; /** Thin hit area for one resize edge/corner, styled to match ResizableHandle. Spread `resizeHandleProps(edge)` onto it. */ -export function DraggableResizeHandleClassName(edge: ResizeEdge, className?: string) { +export function draggableResizeHandleClassName(edge: ResizeEdge, className?: string) { return cn( "absolute z-10 touch-none select-none", EDGE_CURSOR[edge], diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts index 030ae1209ca..455d2b31417 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -75,31 +75,54 @@ describe("clampRectToViewport", () => { describe("resizeRect", () => { const start = { x: 100, y: 100, w: 300, h: 200 }; const minSize = { w: 100, h: 80 }; + // Generous viewport so it never becomes the binding constraint for `start`-based cases. + const viewport = { width: 1000, height: 800 }; + const padding = 10; it("east edge grows width, keeps x/y", () => { - expect(resizeRect("e", start, 50, 0, minSize)).toEqual({ x: 100, y: 100, w: 350, h: 200 }); + expect(resizeRect("e", start, 50, 0, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 100, + w: 350, + h: 200, + }); }); it("south edge grows height, keeps x/y", () => { - expect(resizeRect("s", start, 0, 40, minSize)).toEqual({ x: 100, y: 100, w: 300, h: 240 }); + expect(resizeRect("s", start, 0, 40, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 100, + w: 300, + h: 240, + }); }); it("west edge shrinks width and moves x to keep the right edge fixed", () => { - expect(resizeRect("w", start, 50, 0, minSize)).toEqual({ x: 150, y: 100, w: 250, h: 200 }); + expect(resizeRect("w", start, 50, 0, minSize, undefined, viewport, padding)).toEqual({ + x: 150, + y: 100, + w: 250, + h: 200, + }); }); it("north edge shrinks height and moves y to keep the bottom edge fixed", () => { - expect(resizeRect("n", start, 0, 30, minSize)).toEqual({ x: 100, y: 130, w: 300, h: 170 }); + expect(resizeRect("n", start, 0, 30, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 130, + w: 300, + h: 170, + }); }); it("corner edges combine both axes", () => { - expect(resizeRect("nw", start, 20, 20, minSize)).toEqual({ + expect(resizeRect("nw", start, 20, 20, minSize, undefined, viewport, padding)).toEqual({ x: 120, y: 120, w: 280, h: 180, }); - expect(resizeRect("se", start, -20, -20, minSize)).toEqual({ + expect(resizeRect("se", start, -20, -20, minSize, undefined, viewport, padding)).toEqual({ x: 100, y: 100, w: 280, @@ -108,18 +131,63 @@ describe("resizeRect", () => { }); it("respects min size when shrinking past it", () => { - expect(resizeRect("e", start, -1000, 0, minSize)).toEqual({ x: 100, y: 100, w: 100, h: 200 }); + expect(resizeRect("e", start, -1000, 0, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 100, + w: 100, + h: 200, + }); // west edge: width clamps to min, x stops moving with it - expect(resizeRect("w", start, 1000, 0, minSize)).toEqual({ x: 300, y: 100, w: 100, h: 200 }); + expect(resizeRect("w", start, 1000, 0, minSize, undefined, viewport, padding)).toEqual({ + x: 300, + y: 100, + w: 100, + h: 200, + }); }); it("respects max size when growing past it", () => { const maxSize = { w: 400, h: 300 }; - expect(resizeRect("se", start, 1000, 1000, minSize, maxSize)).toEqual({ + expect(resizeRect("se", start, 1000, 1000, minSize, maxSize, viewport, padding)).toEqual({ x: 100, y: 100, w: 400, h: 300, }); }); + + it("caps west-edge growth at maxSize.w and keeps the right edge fixed", () => { + const maxSize = { w: 250, h: 300 }; + const result = resizeRect("w", start, -1000, 0, minSize, maxSize, viewport, padding); + expect(result).toEqual({ x: 150, y: 100, w: 250, h: 200 }); + expect(result.x + result.w).toBe(start.x + start.w); + }); + + it("caps north-edge growth at maxSize.h and keeps the bottom edge fixed", () => { + const maxSize = { w: 400, h: 150 }; + const result = resizeRect("n", start, 0, -1000, minSize, maxSize, viewport, padding); + expect(result).toEqual({ x: 100, y: 150, w: 300, h: 150 }); + expect(result.y + result.h).toBe(start.y + start.h); + }); + + it("caps west-edge growth at the viewport padding and keeps the right edge fixed", () => { + const nearLeftEdge = { x: 20, y: 100, w: 300, h: 200 }; + const result = resizeRect("w", nearLeftEdge, -10000, 0, minSize, undefined, viewport, padding); + expect(result.x).toBe(padding); + expect(result.x + result.w).toBe(nearLeftEdge.x + nearLeftEdge.w); + }); + + it("caps north-edge growth at the viewport padding and keeps the bottom edge fixed", () => { + const nearTopEdge = { x: 100, y: 15, w: 300, h: 200 }; + const result = resizeRect("n", nearTopEdge, 0, -10000, minSize, undefined, viewport, padding); + expect(result.y).toBe(padding); + expect(result.y + result.h).toBe(nearTopEdge.y + nearTopEdge.h); + }); + + it("caps east-edge growth at the viewport padding", () => { + const nearRightEdge = { x: 850, y: 100, w: 300, h: 200 }; + const result = resizeRect("e", nearRightEdge, 10000, 0, minSize, undefined, viewport, padding); + expect(result.x).toBe(nearRightEdge.x); + expect(result.x + result.w).toBe(viewport.width - padding); + }); }); diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.ts b/apps/webapp/app/components/primitives/draggableResizableMath.ts index f46bf6b5872..fe19168930c 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -43,8 +43,10 @@ export function clampRectToViewport(rect: Rect, viewport: Viewport, padding: num } /** - * Applies a pointer delta to `start` for the given resize edge, respecting min/max size. - * North/west edges move the opposite corner too so the far edge stays put. + * Applies a pointer delta to `start` for the given resize edge, respecting min/max size + * and the viewport bounds. North/west edges move the opposite corner too so the far edge + * stays put — the per-edge cap is derived from the *fixed* far edge, so growth can never + * push it past the viewport padding. */ export function resizeRect( edge: ResizeEdge, @@ -52,22 +54,28 @@ export function resizeRect( dx: number, dy: number, minSize: Size, - maxSize?: Size + maxSize: Size | undefined, + viewport: Viewport, + padding: number ): Rect { let { x, y, w, h } = start; if (edge.includes("e")) { - w = clamp(start.w + dx, minSize.w, maxSize?.w ?? Infinity); + const maxW = Math.min(maxSize?.w ?? Infinity, viewport.width - padding - start.x); + w = clamp(start.w + dx, minSize.w, maxW); } if (edge.includes("s")) { - h = clamp(start.h + dy, minSize.h, maxSize?.h ?? Infinity); + const maxH = Math.min(maxSize?.h ?? Infinity, viewport.height - padding - start.y); + h = clamp(start.h + dy, minSize.h, maxH); } if (edge.includes("w")) { - w = clamp(start.w - dx, minSize.w, maxSize?.w ?? Infinity); + const maxW = Math.min(maxSize?.w ?? Infinity, start.x + start.w - padding); + w = clamp(start.w - dx, minSize.w, maxW); x = start.x + (start.w - w); } if (edge.includes("n")) { - h = clamp(start.h - dy, minSize.h, maxSize?.h ?? Infinity); + const maxH = Math.min(maxSize?.h ?? Infinity, start.y + start.h - padding); + h = clamp(start.h - dy, minSize.h, maxH); y = start.y + (start.h - h); } diff --git a/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx b/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx index 2fc7cad4da5..43db4997150 100644 --- a/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx +++ b/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx @@ -1,7 +1,7 @@ import { motion } from "framer-motion"; import { ComponentNames } from "../storybook/StoryKit"; import { - DraggableResizeHandleClassName, + draggableResizeHandleClassName, useDraggableResizable, type ResizeEdge, } from "~/components/primitives/DraggableResizable"; @@ -37,7 +37,7 @@ export default function Story() { ))} From 8d8b0395f0114644206024736b03ddf8f3217c4a Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 14:20:36 +0000 Subject: [PATCH 03/43] feat(webapp): dashboard agent as a floating bottom-right window Replace the slide-in right column with a draggable, resizable floating chat window (variant-1 style), default and only mode now. --- .../dashboard-agent/DashboardAgent.tsx | 61 ++++++-------- .../dashboard-agent/DashboardAgentPanel.tsx | 37 +++++---- .../dashboard-agent/panel-layout.tsx | 74 +++++++++++++++++ .../route.tsx | 79 +++++++++++++++++++ 4 files changed, 198 insertions(+), 53 deletions(-) create mode 100644 apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index 3d356d9b548..f6f2a3e3269 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -1,11 +1,6 @@ import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contracts"; import { useLocation } from "@remix-run/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; @@ -17,7 +12,7 @@ import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentL import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; import { agentHiddenContentClassName, - agentTakeoverClassName, + FloatingAgentWindow, readAgentFullscreen, writeAgentFullscreen, } from "./panel-layout"; @@ -346,39 +341,29 @@ export function DashboardAgent({ return ( {open ? ( - // `relative` is the takeover's containing block. + // `relative` is the fullscreen takeover's containing block; the non-fullscreen + // window is a page-wide floating overlay and doesn't need it.
- - -
{children}
-
- - -
- setPanelOpen(false)} - requestedMessage={requestedMessage} - openChatRequest={openChatRequest} - watchRequest={watchRequest} - newChatSeq={newChatSeq} - promotedPrompt={promotedPrompt} - onChatRead={markChatRead} - // The panel's own count, off the chat list it has already marked read. - onUnreadWorkChange={setUnreadWork} - onTurnActivityChange={handleTurnActivityChange} - isFullscreen={fullscreen} - onToggleFullscreen={toggleFullscreen} - /> -
-
-
+
{children}
+ + {(dragHandleProps) => ( + setPanelOpen(false)} + requestedMessage={requestedMessage} + openChatRequest={openChatRequest} + watchRequest={watchRequest} + newChatSeq={newChatSeq} + promotedPrompt={promotedPrompt} + onChatRead={markChatRead} + // The panel's own count, off the chat list it has already marked read. + onUnreadWorkChange={setUnreadWork} + onTurnActivityChange={handleTurnActivityChange} + isFullscreen={fullscreen} + onToggleFullscreen={toggleFullscreen} + dragHandleProps={dragHandleProps} + /> + )} +
) : (
{children}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index 986c3f9bdf7..740800558ed 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -1,7 +1,9 @@ import type { UIMessage } from "@ai-sdk/react"; import { useLocation } from "@remix-run/react"; import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic"; +import { motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import type { PanHandlerProps } from "~/components/primitives/DraggableResizable"; import { AgentSpinner } from "~/components/primitives/Spinner"; import { useToast } from "~/components/primitives/Toast"; import { useAgentPageContext } from "~/hooks/useAgentPageContext"; @@ -84,10 +86,13 @@ export function DashboardAgentPanel({ onTurnActivityChange, isFullscreen = false, onToggleFullscreen, + dragHandleProps, }: { onClose: () => void; isFullscreen?: boolean; onToggleFullscreen?: () => void; + /** Spread onto the header, which is the floating window's drag handle. */ + dragHandleProps?: Partial; // Every `seq` below distinguishes repeat requests with identical contents. requestedMessage?: { text: string; seq: number }; openChatRequest?: { chatId: string; seq: number }; @@ -589,7 +594,7 @@ export function DashboardAgentPanel({ return (
{ if ( @@ -604,20 +609,22 @@ export function DashboardAgentPanel({ onClose(); }} > - {})} - isFullscreen={isFullscreen} - onClose={onClose} - /> + + {})} + isFullscreen={isFullscreen} + onClose={onClose} + /> + {/* Always mounted, so the chat keeps its transport, session and transcript. */} diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index 15f581ed310..a10b4f1d1c3 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -1,9 +1,46 @@ // Both class helpers apply to always-rendered wrappers, so toggling fullscreen is a // class change only and the open chat's transport, session and transcript survive it. +import { useMemo } from "react"; +import { motion } from "framer-motion"; +import { + useDraggableResizable, + type PanHandlerProps, + type ResizeEdge, +} from "~/components/primitives/DraggableResizable"; import { cn } from "~/utils/cn"; const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen"; +// V1 floating window: 380x512, bottom-right, matching the gallery's own panel frame. +const FLOATING_WIDTH = 380; +const FLOATING_HEIGHT = 512; +const FLOATING_MARGIN = 16; +const FLOATING_MIN_SIZE = { w: 320, h: 360 }; +const RESIZE_EDGES: ResizeEdge[] = ["n", "e", "s", "w", "ne", "nw", "se", "sw"]; + +const RESIZE_HANDLE_CLASS: Record = { + n: "absolute inset-x-2 top-0 h-1.5 cursor-n-resize", + s: "absolute inset-x-2 bottom-0 h-1.5 cursor-s-resize", + e: "absolute inset-y-2 right-0 w-1.5 cursor-e-resize", + w: "absolute inset-y-2 left-0 w-1.5 cursor-w-resize", + ne: "absolute right-0 top-0 size-3 cursor-ne-resize", + nw: "absolute left-0 top-0 size-3 cursor-nw-resize", + se: "absolute right-0 bottom-0 size-3 cursor-se-resize", + sw: "absolute left-0 bottom-0 size-3 cursor-sw-resize", +}; + +function initialFloatingRect() { + if (typeof window === "undefined") { + return { x: 0, y: 0, w: FLOATING_WIDTH, h: FLOATING_HEIGHT }; + } + return { + x: window.innerWidth - FLOATING_WIDTH - FLOATING_MARGIN, + y: window.innerHeight - FLOATING_HEIGHT - FLOATING_MARGIN, + w: FLOATING_WIDTH, + h: FLOATING_HEIGHT, + }; +} + export function readAgentFullscreen(): boolean { if (typeof window === "undefined") return false; try { @@ -32,6 +69,43 @@ export function agentHiddenContentClassName(fullscreen: boolean): string { return cn("h-full overflow-hidden", fullscreen && "invisible"); } +/** + * The floating chat window: `useDraggableResizable`-positioned bottom-right, draggable + * and resizable across the whole page. Fullscreen swaps it back to the same takeover the + * old right-column mode used, which needs `children` positioned inside a `relative` + * ancestor — the caller (`DashboardAgent`) supplies that. + */ +export function FloatingAgentWindow({ + fullscreen, + children, +}: { + fullscreen: boolean; + children: (dragHandleProps: Partial) => React.ReactNode; +}) { + const initial = useMemo(() => initialFloatingRect(), []); + const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({ + initial, + minSize: FLOATING_MIN_SIZE, + viewportPadding: FLOATING_MARGIN, + }); + + if (fullscreen) { + return
{children({})}
; + } + + return ( +
+ {children(dragHandleProps)} + {RESIZE_EDGES.map((edge) => ( + + ))} +
+ ); +} + export function AgentPanelColumn({ fullscreen, children, diff --git a/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx new file mode 100644 index 00000000000..258e3b880dd --- /dev/null +++ b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx @@ -0,0 +1,79 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; +import { ComponentNames } from "../storybook/StoryKit"; +import { ChatText, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { DashboardAgentHeader } from "~/components/dashboard-agent/DashboardAgentHeader"; +import type { DashboardAgentChat } from "~/components/dashboard-agent/DashboardAgentHistory"; +import { FloatingAgentWindow } from "~/components/dashboard-agent/panel-layout"; +import { Button } from "~/components/primitives/Buttons"; +import { Header1 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; + +const NO_CHATS: DashboardAgentChat[] = []; + +/** + * The dashboard agent's default (and only) mode: a floating window docked at the + * bottom of the page, draggable across the whole page and resizable by its edges and + * corners. Static content only — this demos the shell (`FloatingAgentWindow`, the real + * `DashboardAgentHeader`, and `agentTakeoverClassName` fullscreen), not a live backend. + */ +export default function Story() { + const [open, setOpen] = useState(true); + const [fullscreen, setFullscreen] = useState(false); + + return ( +
+
+ +
+
+ Dashboard agent — floating window + + Drag the header anywhere on the page, resize from any edge or corner. Expand takes over + the page the same way the old side panel did. + +
+ {!open && ( +
+ +
+ )} + {open && ( + + {(dragHandleProps) => ( +
+ + {}} + showNewChat={false} + onOpenHistory={() => {}} + onSelectChat={() => {}} + onDeleteChat={() => {}} + onToggleFullscreen={() => setFullscreen((f) => !f)} + isFullscreen={fullscreen} + onClose={() => setOpen(false)} + /> + + + + + + + + + +
+ )} +
+ )} +
+ ); +} From 4b93738cb8b3263f58d30c0fa2fbba89a2f2122d Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 14:30:26 +0000 Subject: [PATCH 04/43] fix(webapp): floating chat window review follow-ups Drop dead exports for knip, register the storybook route, grab cursor on the drag handle, and a source guard against the right-column mode coming back. --- .../dashboard-agent/DashboardAgentPanel.tsx | 16 +++++++++++- .../floating-window-mode.test.ts | 26 +++++++++++++++++++ .../dashboard-agent/panel-layout.tsx | 2 +- apps/webapp/app/routes/storybook/route.tsx | 1 + 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index 740800558ed..462d1dd16f5 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -53,6 +53,7 @@ import { import { AgentPanelColumn } from "./panel-layout"; import { markerAfterActiveChat, markerAfterActivity } from "./thinking-marker"; import { concurrencyPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; function serializePageContext(pageContext: AgentPageContext): string | undefined { try { @@ -132,6 +133,8 @@ export function DashboardAgentPanel({ const [loading, setLoading] = useState( () => readLastChat(storageKey)?.path === location.pathname ); + // Cursor feedback only; the drag itself is handled by `dragHandleProps`. + const [draggingWindow, setDraggingWindow] = useState(false); const currentPage = agentPageLabel(pageContext, location.pathname); @@ -609,7 +612,18 @@ export function DashboardAgentPanel({ onClose(); }} > - + { + setDraggingWindow(true); + dragHandleProps?.onPanStart?.(event, info); + }} + onPanEnd={(event, info) => { + setDraggingWindow(false); + dragHandleProps?.onPanEnd?.(event, info); + }} + className={cn("select-none", draggingWindow ? "cursor-grabbing" : "cursor-grab")} + > { + const source = read("DashboardAgent.tsx"); + + it("never reintroduces the right-column ResizablePanelGroup", () => { + expect(source).not.toContain("ResizablePanelGroup"); + expect(source).not.toContain("ResizablePanel"); + expect(source).not.toContain("ResizableHandle"); + }); + + it("renders the open panel through FloatingAgentWindow", () => { + expect(source).toContain("FloatingAgentWindow"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index a10b4f1d1c3..a35d5d1fba6 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -59,7 +59,7 @@ export function writeAgentFullscreen(fullscreen: boolean): void { } } -export function agentTakeoverClassName(fullscreen: boolean): string { +function agentTakeoverClassName(fullscreen: boolean): string { return fullscreen ? "absolute inset-0 z-10 bg-background-bright" : "h-full"; } diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index e76ba520236..f754a3ec14e 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -137,6 +137,7 @@ const sections: StorySection[] = [ { name: "Investigation card", slug: "agent-investigation" }, { name: "Watch card", slug: "agent-watch" }, { name: "Icons & Buttons", slug: "ai-agent" }, + { name: "Floating chat window", slug: "dashboard-agent-floating" }, ], }, ]; From 9e3662834a7da4f0697e60ec90c654c6640d90aa Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 15:36:27 +0000 Subject: [PATCH 05/43] fix(webapp): keep the Ask Trigger button visible while chat is open Clicking it while open re-affirms the single floating window instead of doing nothing, matching what live testing expected. --- .../dashboard-agent/dashboardAgentLauncher.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index 052824aa25c..f864ec70600 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -46,12 +46,11 @@ export function DashboardAgentLauncher() { } const { open, setOpen, unreadWakes, unreadWork } = agent; - if (open) { - return null; - } - const hasUnread = unreadWakes > 0 || unreadWork > 0; + // Stays visible while the window is open: there is only ever one floating window (it's + // fixed-position and already on top of everything), so a click while open is a no-op — it + // never spawns a second window, it just re-affirms the one that's already there. return ( - Open chat + {open ? "Chat open" : "Open chat"} } From b9e9e14814224cfed8df28bdc3c3e74e83985e16 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 15:47:48 +0000 Subject: [PATCH 06/43] fix(webapp): eliminate the drag/resize gesture-baseline race framer-motion defers onPanStart/onPanEnd by a frame (its internal scheduler) but calls onPan synchronously, so a gesture-start rect snapshot captured in onPanStart could still be stale (or the mount-time initial rect) when a gesture's first onPan landed. Switch to framer's per-event `delta` folded onto the latest committed rect via functional setState, so there's no baseline left to race. --- .../primitives/DraggableResizable.tsx | 56 ++++++-------- .../primitives/draggableResizableMath.test.ts | 75 +++++++++++++++++++ .../primitives/draggableResizableMath.ts | 42 +++++++++++ 3 files changed, 139 insertions(+), 34 deletions(-) diff --git a/apps/webapp/app/components/primitives/DraggableResizable.tsx b/apps/webapp/app/components/primitives/DraggableResizable.tsx index 33cb19c0f76..7531eb7dc61 100644 --- a/apps/webapp/app/components/primitives/DraggableResizable.tsx +++ b/apps/webapp/app/components/primitives/DraggableResizable.tsx @@ -1,11 +1,12 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; import { type PanInfo } from "framer-motion"; import { cn } from "~/utils/cn"; import { + applyDragDelta, + applyResizeDelta, clampPosition, clampRectToViewport, clampSize, - resizeRect, type Point, type Rect, type ResizeEdge, @@ -62,13 +63,6 @@ export function useDraggableResizable({ const size = clampSize({ w: initial.w, h: initial.h }, minSize, maxSize); return { ...clampPosition(initial, size, getViewport(), viewportPadding), ...size }; }); - const rectRef = useRef(rect); - // oxlint-disable-next-line react/refs -- mirrors state into a ref for use inside gesture callbacks, not for rendering. - rectRef.current = rect; - - // Snapshot of the rect at gesture start; framer-motion's PanInfo.offset is - // cumulative from pan start, so every onPan step re-applies it to this. - const startRectRef = useRef(rect); // Re-clamp on viewport resize (and once on mount, since SSR renders against // an unbounded viewport) so the box never strands off-screen. @@ -81,40 +75,34 @@ export function useDraggableResizable({ return () => window.removeEventListener("resize", onResize); }, [viewportPadding]); + // Each onPan step folds `info.delta` (movement since the *last* event, not cumulative) + // onto the latest committed rect via the functional setState form. No gesture-start + // snapshot is kept: framer-motion defers onPanStart/onPanEnd by a frame but calls onPan + // synchronously, so a ref-based baseline captured in onPanStart can still be stale (or + // the mount-time initial rect) when the first onPan of a gesture lands. Delta + functional + // update has no baseline to go stale, so gestures compose correctly back-to-back. const dragHandleProps: PanHandlerProps = { - onPanStart: () => { - startRectRef.current = rectRef.current; - }, + onPanStart: () => {}, onPan: (_event, info: PanInfo) => { - const startRect = startRectRef.current; - const nextPosition = clampPosition( - { x: startRect.x + info.offset.x, y: startRect.y + info.offset.y }, - { w: startRect.w, h: startRect.h }, - getViewport(), - viewportPadding - ); - setRect((current) => ({ ...current, ...nextPosition })); + setRect((current) => applyDragDelta(current, info.delta, getViewport(), viewportPadding)); }, onPanEnd: () => {}, }; const resizeHandleProps = (edge: ResizeEdge): PanHandlerProps => ({ - onPanStart: () => { - startRectRef.current = rectRef.current; - }, + onPanStart: () => {}, onPan: (_event, info: PanInfo) => { - const startRect = startRectRef.current; - const resized = resizeRect( - edge, - startRect, - info.offset.x, - info.offset.y, - minSize, - maxSize, - getViewport(), - viewportPadding + setRect((current) => + applyResizeDelta( + edge, + current, + info.delta, + minSize, + maxSize, + getViewport(), + viewportPadding + ) ); - setRect(clampRectToViewport(resized, getViewport(), viewportPadding)); }, onPanEnd: () => {}, }); diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts index 455d2b31417..b44c6d6d4c2 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest"; import { + applyDragDelta, + applyResizeDelta, clamp, clampPosition, clampRectToViewport, clampSize, resizeRect, + type Rect, } from "./draggableResizableMath"; describe("clamp", () => { @@ -191,3 +194,75 @@ describe("resizeRect", () => { expect(result.x + result.w).toBe(viewport.width - padding); }); }); + +// These reproduce the live-testing symptoms: framer-motion defers onPanStart/onPanEnd by a +// frame (via its internal scheduler) but calls onPan synchronously, so a gesture-start +// snapshot captured in onPanStart can be stale — or still the mount-time initial rect — when +// a gesture's first onPan lands. applyDragDelta/applyResizeDelta take framer's per-event +// `delta` (not the cumulative `offset`) and fold it onto whatever rect is passed in, so the +// hook can drive them with `setRect(current => apply...(current, ...))` and never needs a +// separate baseline that could go stale. Simulating "gesture A steps, then gesture B steps, +// no reset in between" is exactly what a stale-baseline bug would fail on. +describe("applyResizeDelta / applyDragDelta — gesture sequencing", () => { + const start: Rect = { x: 100, y: 100, w: 300, h: 200 }; + const minSize = { w: 100, h: 80 }; + const viewport = { width: 1000, height: 800 }; + const padding = 10; + + it("symptom 1: a second resize gesture on the same edge continues from the first gesture's end, with no reset between them", () => { + let rect = start; + // Gesture A: five 4px steps east (total +20). + for (let i = 0; i < 5; i++) { + rect = applyResizeDelta("e", rect, { x: 4, y: 0 }, minSize, undefined, viewport, padding); + } + expect(rect.w).toBe(320); + + // Gesture B starts immediately — no onPanStart-equivalent call, matching framer's + // deferred-onPanStart timing where the first onPan of a new gesture can land first. + for (let i = 0; i < 3; i++) { + rect = applyResizeDelta("e", rect, { x: 10, y: 0 }, minSize, undefined, viewport, padding); + } + // Continues from gesture A's end (320), not from a stale baseline (e.g. back to 300). + expect(rect.w).toBe(350); + }); + + it("symptom 2: a drag gesture right after a resize gesture continues from the resized rect, not a stale one", () => { + let rect = applyResizeDelta( + "se", + start, + { x: 50, y: 30 }, + minSize, + undefined, + viewport, + padding + ); + expect(rect).toEqual({ x: 100, y: 100, w: 350, h: 230 }); + + // Drag starts immediately after, no reset — same race window as symptom 2. + rect = applyDragDelta(rect, { x: 20, y: 5 }, viewport, padding); + expect(rect).toEqual({ x: 120, y: 105, w: 350, h: 230 }); + }); + + it("symptom 3/4: a resize right after a drag continues from the dragged position, never snapping back toward a stale/initial rect", () => { + // Move well away from wherever `start` or a mount-time initial rect might sit. + let rect = applyDragDelta(start, { x: 200, y: 150 }, viewport, padding); + expect(rect).toEqual({ x: 300, y: 250, w: 300, h: 200 }); + + // Resizing next must clamp against the *current* x/y (300, 250), not `start` (100, 100) + // or any other stale baseline — a stale baseline pinned near the right edge would show + // up here as the box jumping back toward x=690 (the viewport-clamped position for `start` + // near the right edge) instead of resizing in place. + rect = applyResizeDelta("e", rect, { x: 10, y: 0 }, minSize, undefined, viewport, padding); + expect(rect.x).toBe(300); + expect(rect.w).toBe(310); + }); + + it("dragging right never magnets to the viewport edge before the box actually reaches it", () => { + let rect: Rect = { x: 500, y: 100, w: 300, h: 200 }; + // Small rightward steps, well short of the right edge (max x = 1000 - 10 - 300 = 690). + for (let i = 0; i < 5; i++) { + rect = applyDragDelta(rect, { x: 10, y: 0 }, viewport, padding); + } + expect(rect.x).toBe(550); + }); +}); diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.ts b/apps/webapp/app/components/primitives/draggableResizableMath.ts index fe19168930c..42e8ad22f2e 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -81,3 +81,45 @@ export function resizeRect( return { x, y, w, h }; } + +/** + * Applies one incremental pan step (framer-motion's `PanInfo.delta` — the movement since + * the *previous* event, not cumulative from gesture start) to `current` and re-clamps. + * + * Deliberately incremental rather than start-snapshot + cumulative-offset: framer-motion + * defers `onPanStart`/`onPanEnd` by a frame (via its internal scheduler) while `onPan` + * fires synchronously, so a start-rect ref captured in `onPanStart` can still hold a + * stale (or the mount-time initial) value when the gesture's first `onPan` lands — every + * later step then computes off the wrong baseline. Folding each step onto `current` + * (always the latest committed rect, via React's functional `setState`) has no baseline + * to go stale, so the race can't happen. Safe to call across gesture boundaries with no + * reset in between — each call is self-contained. + */ +export function applyDragDelta( + current: Rect, + delta: Point, + viewport: Viewport, + padding: number +): Rect { + const nextPosition = clampPosition( + { x: current.x + delta.x, y: current.y + delta.y }, + { w: current.w, h: current.h }, + viewport, + padding + ); + return { ...current, ...nextPosition }; +} + +/** Resize counterpart of {@link applyDragDelta} — same incremental-step rationale. */ +export function applyResizeDelta( + edge: ResizeEdge, + current: Rect, + delta: Point, + minSize: Size, + maxSize: Size | undefined, + viewport: Viewport, + padding: number +): Rect { + const resized = resizeRect(edge, current, delta.x, delta.y, minSize, maxSize, viewport, padding); + return clampRectToViewport(resized, viewport, padding); +} From a07500a373abe599c871989976a4d9d82b6e5110 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 15:55:55 +0000 Subject: [PATCH 07/43] test(webapp): jsdom regression test for the pan-gesture ordering fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function tests can't fail on the deferred-onPanStart race by construction. This drives the hook's real handlers in framer's actual order (onPan, onPan, then a late onPanStart, then onPan) — fails against an offset+baseline shape, passes with the delta fold. --- .../primitives/DraggableResizable.dom.test.ts | 95 ++++++ apps/webapp/package.json | 1 + pnpm-lock.yaml | 314 +++++++++++++++++- 3 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts diff --git a/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts new file mode 100644 index 00000000000..426af1477cc --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +// +// Drives the hook's own handlers in framer-motion's *real* callback order, not the +// pure math functions: framer-motion defers onPanStart/onPanEnd by a frame (its internal +// scheduler) while onPan fires synchronously, so a real gesture can deliver one or more +// onPan events before the onPanStart for that same gesture lands. A startRectRef-based +// implementation resets its baseline to the *already-moved* rect when the late onPanStart +// finally fires, corrupting every subsequent onPan in the gesture. This file proves the +// hook survives that ordering; draggableResizableMath.test.ts's pure-function tests can't, +// since they call the (already-fixed) math directly and have no callback ordering to get +// wrong. +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import type { PanInfo } from "framer-motion"; +import { + useDraggableResizable, + type UseDraggableResizableOptions, + type UseDraggableResizableResult, +} from "./DraggableResizable"; + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + } + container?.remove(); + container = undefined; + root = undefined; +}); + +function renderHook(options: UseDraggableResizableOptions) { + let latest!: UseDraggableResizableResult; + function Harness() { + // oxlint-disable-next-line react/globals -- test harness capturing the hook's return value. + latest = useDraggableResizable(options); + return null; + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness)); + }); + return { + get current() { + return latest; + }, + }; +} + +// `offset` is populated alongside `delta` (framer-motion always sends both) so a reverted +// offset+baseline implementation runs its real math instead of crashing on `undefined` — +// it must fail on the *value*, not on a missing field. +function fakePanInfo(deltaX: number, offsetX: number): PanInfo { + return { + delta: { x: deltaX, y: 0 }, + offset: { x: offsetX, y: 0 }, + point: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + }; +} + +const fakeEvent = {} as PointerEvent; + +describe("useDraggableResizable — framer's real onPan/onPanStart ordering", () => { + const initial = { x: 100, y: 100, w: 300, h: 200 }; + const minSize = { w: 100, h: 80 }; + + it("drag: two onPan events land before their onPanStart, and the gesture still ends up at initial.x + cumulative delta", () => { + const hook = renderHook({ initial, minSize }); + + act(() => hook.current.dragHandleProps.onPan(fakeEvent, fakePanInfo(10, 10))); + act(() => hook.current.dragHandleProps.onPan(fakeEvent, fakePanInfo(10, 20))); + // Late on purpose: framer-motion's onStart is scheduled via its frame queue, onMove isn't. + act(() => hook.current.dragHandleProps.onPanStart(fakeEvent, fakePanInfo(0, 20))); + act(() => hook.current.dragHandleProps.onPan(fakeEvent, fakePanInfo(10, 30))); + + expect(hook.current.position.x).toBe(initial.x + 30); + }); + + it("resize: two onPan events land before their onPanStart, and the gesture still ends up at initial.w + cumulative delta", () => { + const hook = renderHook({ initial, minSize }); + + act(() => hook.current.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(10, 10))); + act(() => hook.current.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(10, 20))); + act(() => hook.current.resizeHandleProps("e").onPanStart(fakeEvent, fakePanInfo(0, 20))); + act(() => hook.current.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(10, 30))); + + expect(hook.current.size.w).toBe(initial.w + 30); + }); +}); diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 788342f23bb..db786ed44a8 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -255,6 +255,7 @@ "engine.io": "^6.6.7", "esbuild": "^0.15.10", "evalite": "1.0.0-beta.16", + "jsdom": "^30.0.1", "supertest": "^7.0.0", "tailwind-scrollbar": "^4.0.2", "tsx": "^4.20.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7a0a3e5d54..ca0822bdfc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) apps/supervisor: dependencies: @@ -874,6 +874,9 @@ importers: evalite: specifier: 1.0.0-beta.16 version: 1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 supertest: specifier: ^7.0.0 version: 7.0.0 @@ -992,7 +995,7 @@ importers: version: link:../../packages/cli-v3 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/dashboard-agent-contracts: dependencies: @@ -1005,7 +1008,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/dashboard-agent-db: dependencies: @@ -1040,7 +1043,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/emails: dependencies: @@ -1096,7 +1099,7 @@ importers: version: link:../testcontainers vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/metrics-pipeline: dependencies: @@ -1137,7 +1140,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/otlp-importer: dependencies: @@ -1286,7 +1289,7 @@ importers: version: 6.0.1 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/run-store: dependencies: @@ -1357,7 +1360,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.7 - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) internal-packages/sso: dependencies: @@ -2253,6 +2256,14 @@ packages: '@ark/util@0.46.0': resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -2897,6 +2908,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bufbuild/protobuf@1.10.0': resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} @@ -3048,6 +3063,42 @@ packages: peerDependencies: '@bufbuild/protobuf': ^1.4.2 + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.0': + resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -4205,6 +4256,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -8651,6 +8711,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big.js@6.2.2: resolution: {integrity: sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==} @@ -9356,6 +9419,10 @@ packages: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -9823,6 +9890,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10695,6 +10766,10 @@ packages: resolution: {integrity: sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -10991,6 +11066,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -11147,6 +11225,15 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} @@ -11503,6 +11590,10 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -12484,6 +12575,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseley@0.12.1: resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} @@ -12937,6 +13031,10 @@ packages: pumpify@1.5.1: resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -13557,6 +13655,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -14026,6 +14128,9 @@ packages: peerDependencies: react: 18.3.1 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + sync-content@2.0.4: resolution: {integrity: sha512-w3ioiBmbaogob33WdLnuwFk+8tpePI58CTWKqtdAgEqc2hfGuSwP02gPETqNX/3PLS5skv5a1wQR0gbaa2W0XQ==} engines: {node: 20 || >=22} @@ -14211,9 +14316,17 @@ packages: toposort@2.0.2: resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -14510,6 +14623,10 @@ packages: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -14856,6 +14973,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -14883,6 +15004,22 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -14969,10 +15106,17 @@ packages: resolution: {integrity: sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw==} engines: {node: '>= 4.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.0.0: resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} engines: {node: '>=0.4.0'} @@ -15263,6 +15407,21 @@ snapshots: '@ark/util@0.46.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -17009,6 +17168,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bufbuild/protobuf@1.10.0': {} '@bugsnag/cuid@3.1.1': {} @@ -17286,6 +17449,30 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} '@depot/cli-darwin-arm64@0.0.1-cli.2.80.0': @@ -17942,6 +18129,8 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@exodus/bytes@1.15.1': {} + '@fastify/accept-negotiator@2.0.1': {} '@fastify/ajv-compiler@4.0.5': @@ -22376,7 +22565,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) '@vitest/expect@4.1.7': dependencies: @@ -22871,6 +23060,10 @@ snapshots: prebuild-install: 7.1.3 optional: true + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big.js@6.2.2: {} binary-extensions@2.2.0: {} @@ -23659,6 +23852,13 @@ snapshots: data-uri-to-buffer@3.0.1: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.8 @@ -24054,6 +24254,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -25312,6 +25514,12 @@ snapshots: dependencies: lru-cache: 7.18.3 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-to-text@9.0.5: @@ -25579,6 +25787,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-reference@3.0.3: @@ -25708,6 +25918,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsep@1.4.0: {} jsesc@3.0.2: {} @@ -26020,6 +26256,8 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@11.5.2: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -27047,7 +27285,7 @@ snapshots: node-abi@3.89.0: dependencies: - semver: 7.8.5 + semver: 7.8.1 optional: true node-abort-controller@3.1.1: {} @@ -27503,6 +27741,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -27938,6 +28180,8 @@ snapshots: inherits: 2.0.4 pump: 2.0.1 + punycode@2.3.1: {} + pure-rand@6.1.0: {} qrcode.react@4.2.0(react@18.3.1): @@ -28722,6 +28966,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -29309,6 +29557,8 @@ snapshots: react: 18.3.1 use-sync-external-store: 1.2.2(react@18.3.1) + symbol-tree@3.2.4: {} + sync-content@2.0.4: dependencies: glob: 13.0.6 @@ -29521,8 +29771,16 @@ snapshots: toposort@2.0.2: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -29853,6 +30111,8 @@ snapshots: undici@7.29.0: {} + undici@8.10.0: {} + unicode-emoji-modifier-base@1.0.0: {} unicorn-magic@0.1.0: {} @@ -30203,7 +30463,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 '@vitest/mocker': 4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@3.12.2)(yaml@2.9.0)) @@ -30229,10 +30489,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 30.0.1 transitivePeerDependencies: - msw - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.7)(jsdom@30.0.1)(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 '@vitest/mocker': 4.1.7(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.22.4)(yaml@2.9.0)) @@ -30258,11 +30519,16 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 30.0.1 transitivePeerDependencies: - msw w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} warning@4.0.3: @@ -30287,6 +30553,26 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -30374,8 +30660,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.0.0: {} xtend@4.0.2: {} From c2044a12735986846d256efa119514601aa026b9 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:02:32 +0000 Subject: [PATCH 08/43] fix(webapp): Ask Trigger button closes the chat when clicked while open Toggles instead of re-affirming a no-op; tooltip says "Close chat" when open. --- .../dashboard-agent/dashboardAgentLauncher.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index f864ec70600..d3fb7bf1f01 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -48,9 +48,8 @@ export function DashboardAgentLauncher() { const { open, setOpen, unreadWakes, unreadWork } = agent; const hasUnread = unreadWakes > 0 || unreadWork > 0; - // Stays visible while the window is open: there is only ever one floating window (it's - // fixed-position and already on top of everything), so a click while open is a no-op — it - // never spawns a second window, it just re-affirms the one that's already there. + // Stays visible while the window is open, and toggles it: there is only ever one floating + // window, so open->click closes it rather than re-affirming a no-op. return ( - {open ? "Chat open" : "Open chat"} + {open ? "Close chat" : "Open chat"} } @@ -67,7 +66,7 @@ export function DashboardAgentLauncher() { From 6e80fbe2e3c1e7b56ae593cf744075e7e8af9112 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:07:53 +0000 Subject: [PATCH 09/43] fix(webapp): agent-ui story demos the floating window shell Chat UI gallery gets a live floating-window section (drag, resize, real header) and drops the "side panel" wording. Reuse draggableResizeHandleClassName from the primitive instead of a local duplicate. Move the floating-chat-window story next to Chat UI and drop the now-standalone draggable-resizable story. --- .../dashboard-agent/panel-layout.tsx | 18 ++--- .../app/routes/storybook.agent-ui/manifest.ts | 16 ++++- .../app/routes/storybook.agent-ui/route.tsx | 70 ++++++++++++++++++- .../storybook.draggable-resizable/route.tsx | 46 ------------ apps/webapp/app/routes/storybook/route.tsx | 3 +- 5 files changed, 89 insertions(+), 64 deletions(-) delete mode 100644 apps/webapp/app/routes/storybook.draggable-resizable/route.tsx diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index a35d5d1fba6..11eca0e46cb 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { motion } from "framer-motion"; import { + draggableResizeHandleClassName, useDraggableResizable, type PanHandlerProps, type ResizeEdge, @@ -18,17 +19,6 @@ const FLOATING_MARGIN = 16; const FLOATING_MIN_SIZE = { w: 320, h: 360 }; const RESIZE_EDGES: ResizeEdge[] = ["n", "e", "s", "w", "ne", "nw", "se", "sw"]; -const RESIZE_HANDLE_CLASS: Record = { - n: "absolute inset-x-2 top-0 h-1.5 cursor-n-resize", - s: "absolute inset-x-2 bottom-0 h-1.5 cursor-s-resize", - e: "absolute inset-y-2 right-0 w-1.5 cursor-e-resize", - w: "absolute inset-y-2 left-0 w-1.5 cursor-w-resize", - ne: "absolute right-0 top-0 size-3 cursor-ne-resize", - nw: "absolute left-0 top-0 size-3 cursor-nw-resize", - se: "absolute right-0 bottom-0 size-3 cursor-se-resize", - sw: "absolute left-0 bottom-0 size-3 cursor-sw-resize", -}; - function initialFloatingRect() { if (typeof window === "undefined") { return { x: 0, y: 0, w: FLOATING_WIDTH, h: FLOATING_HEIGHT }; @@ -100,7 +90,11 @@ export function FloatingAgentWindow({ > {children(dragHandleProps)} {RESIZE_EDGES.map((edge) => ( - + ))}
); diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index 4870b53a6d5..9ca21a46ec9 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -55,6 +55,7 @@ export type GalleryGroup = | "watches" | "watch-card" | "wakes" + | "shell" | "hero" | "prompts" | "intents" @@ -69,6 +70,7 @@ export type GallerySection = { }; export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [ + { group: "shell", page: "chat", label: "Floating window" }, { group: "hero", page: "chat", label: "Blank-state hero" }, { group: "prompts", page: "chat", label: "Suggested prompts" }, { group: "messages", page: "chat", label: "Message-level states" }, @@ -86,10 +88,20 @@ export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: ]; export const MANIFEST: GallerySection[] = [ - { sectionId: "hero-panel", title: "Side panel (380px) — no page context", group: "hero" }, + { + sectionId: "shell-floating-window", + title: "Draggable, resizable — the default and only chat mode", + group: "shell", + }, + + { + sectionId: "hero-panel", + title: "Floating window content (380px) — no page context", + group: "hero", + }, { sectionId: "hero-panel-contextual", - title: "Side panel — failed run on the page", + title: "Floating window — failed run on the page", group: "hero", }, { sectionId: "hero-fullscreen", title: "Fullscreen takeover — centred column", group: "hero" }, diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index b1b2b732464..e59ba962a64 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,21 +1,84 @@ import type { UIMessage } from "@ai-sdk/react"; import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { motion } from "framer-motion"; import { useState } from "react"; import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo"; -import { ChatProgress, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { + ChatProgress, + ChatText, + ChatTranscript, + ChatTurn, +} from "~/components/dashboard-agent/chat-layout"; import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer"; import { DashboardAgentContextBanner } from "~/components/dashboard-agent/DashboardAgentContextBanner"; +import { DashboardAgentHeader } from "~/components/dashboard-agent/DashboardAgentHeader"; +import type { DashboardAgentChat } from "~/components/dashboard-agent/DashboardAgentHistory"; import { DashboardAgentHero } from "~/components/dashboard-agent/DashboardAgentHero"; import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAgentMessages"; import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts"; -import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout"; +import { AgentPanelColumn, FloatingAgentWindow } from "~/components/dashboard-agent/panel-layout"; import { liveProgress } from "~/components/dashboard-agent/progress-line"; import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; +import { Button } from "~/components/primitives/Buttons"; import { cn } from "~/utils/cn"; import { demoTranscripts, investigationBlock, type DemoTranscript } from "./fixtures"; import { fixtureResolveUri, GalleryPage, noop, PANEL_FRAME } from "./gallery"; +const NO_CHATS: DashboardAgentChat[] = []; + +/** + * The dashboard agent's default (and only) mode: a floating window docked bottom-right, + * draggable across the whole page and resizable by its edges and corners. Toggled open + * here so the section demos the live shell, not a screenshot of it. + */ +function FloatingWindowHarness() { + const [open, setOpen] = useState(true); + const [fullscreen, setFullscreen] = useState(false); + + return ( + <> +
+ +
+ {open && ( + + {(dragHandleProps) => ( +
+ + setFullscreen((current) => !current)} + isFullscreen={fullscreen} + onClose={() => setOpen(false)} + /> + + + + + + + + + +
+ )} +
+ )} + + ); +} + const { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures; function MessageHarness({ @@ -242,6 +305,8 @@ function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeW } const STATES: Record = { + "shell-floating-window": , + "hero-panel": , "hero-panel-contextual": , "hero-fullscreen": , @@ -338,6 +403,7 @@ export default function Story() { page="chat" states={STATES} componentNames={[ + "panel-layout.tsx", "DashboardAgentComposer.tsx", "DashboardAgentMessages.tsx", "DashboardAgentHero.tsx", diff --git a/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx b/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx deleted file mode 100644 index 43db4997150..00000000000 --- a/apps/webapp/app/routes/storybook.draggable-resizable/route.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { motion } from "framer-motion"; -import { ComponentNames } from "../storybook/StoryKit"; -import { - draggableResizeHandleClassName, - useDraggableResizable, - type ResizeEdge, -} from "~/components/primitives/DraggableResizable"; - -const EDGES: ResizeEdge[] = ["n", "e", "s", "w", "ne", "nw", "se", "sw"]; - -export default function Story() { - const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({ - initial: { x: 120, y: 120, w: 360, h: 240 }, - minSize: { w: 200, h: 140 }, - maxSize: { w: 640, h: 480 }, - }); - - return ( -
-
- -
-
- - Drag me - -
- Resize from any edge or corner -
- {EDGES.map((edge) => ( - - ))} -
-
- ); -} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index f754a3ec14e..09e6bcf0639 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -95,7 +95,6 @@ const sections: StorySection[] = [ { name: "Page header", slug: "page-header" }, { name: "Tree view", slug: "tree-view" }, { name: "Resizable", slug: "resizable" }, - { name: "Draggable resizable", slug: "draggable-resizable" }, { name: "Animated panel", slug: "animated-panel" }, { name: "Accordion", slug: "accordion" }, ], @@ -132,12 +131,12 @@ const sections: StorySection[] = [ title: "Trigger Agent", items: [ { name: "Chat UI", slug: "agent-ui" }, + { name: "Floating chat window", slug: "dashboard-agent-floating" }, { name: "View blocks", slug: "agent-view-blocks" }, { name: "Report view", slug: "agent-report" }, { name: "Investigation card", slug: "agent-investigation" }, { name: "Watch card", slug: "agent-watch" }, { name: "Icons & Buttons", slug: "ai-agent" }, - { name: "Floating chat window", slug: "dashboard-agent-floating" }, ], }, ]; From e1dab30f358dcb003c82e2cdda679dd43a1635f1 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:17:48 +0000 Subject: [PATCH 10/43] fix(webapp): close the storybook floating-chat demo on route change Scoped to the storybook stories; the real dashboard chat still persists across navigation. --- .../webapp/app/routes/storybook.agent-ui/route.tsx | 14 +++++++++++++- .../storybook.dashboard-agent-floating/route.tsx | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index e59ba962a64..3ac8a72871b 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,7 +1,8 @@ import type { UIMessage } from "@ai-sdk/react"; import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; +import { useLocation } from "@remix-run/react"; import { motion } from "framer-motion"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo"; import { ChatProgress, @@ -36,6 +37,17 @@ function FloatingWindowHarness() { const [open, setOpen] = useState(true); const [fullscreen, setFullscreen] = useState(false); + // Storybook only: the demo window must not follow you to another story. The real + // dashboard's chat intentionally persists across navigation — this effect is scoped to + // this story route and has no equivalent in DashboardAgent.tsx. + const { pathname } = useLocation(); + const previousPathname = useRef(pathname); + useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; + setOpen(false); + }, [pathname]); + return ( <>
diff --git a/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx index 258e3b880dd..ca67297e23a 100644 --- a/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx +++ b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx @@ -1,5 +1,6 @@ +import { useLocation } from "@remix-run/react"; import { motion } from "framer-motion"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ComponentNames } from "../storybook/StoryKit"; import { ChatText, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; import { DashboardAgentHeader } from "~/components/dashboard-agent/DashboardAgentHeader"; @@ -21,6 +22,17 @@ export default function Story() { const [open, setOpen] = useState(true); const [fullscreen, setFullscreen] = useState(false); + // Storybook only: the demo window must not follow you to another story. The real + // dashboard's chat intentionally persists across navigation — this effect is scoped to + // this story route and has no equivalent in DashboardAgent.tsx. + const { pathname } = useLocation(); + const previousPathname = useRef(pathname); + useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; + setOpen(false); + }, [pathname]); + return (
From 90b4002f4841eaed36867a82e32cad050f615715 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:22:02 +0000 Subject: [PATCH 11/43] feat(webapp): dot-matrix variants of the Investigate/Watch icons for comparison Storybook only, in the ai-agent story's new "Action icons" tab next to the current heroicons versions. Not wired into the app. --- .../app/assets/icons/InvestigateDotIcon.tsx | 25 +++++++ apps/webapp/app/assets/icons/WatchDotIcon.tsx | 25 +++++++ .../webapp/app/assets/icons/dotMatrixIcon.tsx | 51 +++++++++++++ .../app/routes/storybook.ai-agent/route.tsx | 74 ++++++++++++++++++- 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/app/assets/icons/InvestigateDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/WatchDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/dotMatrixIcon.tsx diff --git a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx new file mode 100644 index 00000000000..1fd0d952230 --- /dev/null +++ b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx @@ -0,0 +1,25 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix ("LED"/flip-dot) variant of the Investigate action's magnifying-glass icon +// (heroicons `MagnifyingGlassIcon`), for comparison in storybook.ai-agent. 8x8 grid: a +// coarse ring for the lens, a 3-dot diagonal for the handle. Not used anywhere in the app. +const BITMAP = [ + ".oooo...", + "o....o..", + "o....o..", + "o....o..", + ".oooo...", + ".....o..", + "......o.", + ".......o", +]; + +export function InvestigateDotIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/WatchDotIcon.tsx b/apps/webapp/app/assets/icons/WatchDotIcon.tsx new file mode 100644 index 00000000000..acbe3b0bf71 --- /dev/null +++ b/apps/webapp/app/assets/icons/WatchDotIcon.tsx @@ -0,0 +1,25 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix ("LED"/flip-dot) variant of the Watch action's eye icon (heroicons +// `EyeIcon`), for comparison in storybook.ai-agent. 8x8 grid: an almond outline with a +// 2x2 pupil block. Not used anywhere in the app. +const BITMAP = [ + "........", + "..oooo..", + ".o....o.", + "o..oo..o", + "o..oo..o", + ".o....o.", + "..oooo..", + "........", +]; + +export function WatchDotIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx new file mode 100644 index 00000000000..f66391aed34 --- /dev/null +++ b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx @@ -0,0 +1,51 @@ +/** + * Coarse dot-matrix ("LED"/flip-dot) rendering of an icon silhouette: a bitmap of "o"/"." + * rows becomes a grid of `currentColor` circles at a fixed viewBox, so it drops in + * anywhere a normal icon does. Used by the storybook-only dot-matrix icon variants — + * not wired into any live UI. + */ +export function DotMatrixIcon({ + bitmap, + className, + style, + size = 24, + dotRadius = 1.15, +}: { + /** Equal-length rows, "o" = dot on. Read top to bottom, left to right. */ + bitmap: string[]; + className?: string; + /** `width`/`height` here override `size`, matching how heroicons is usually sized. */ + style?: React.CSSProperties; + size?: number; + dotRadius?: number; +}) { + const rows = bitmap.length; + const cols = bitmap[0]?.length ?? 0; + const cellW = size / cols; + const cellH = size / rows; + + return ( + + {bitmap.flatMap((row, r) => + [...row].map((cell, c) => + cell === "o" ? ( + + ) : null + ) + )} + + ); +} diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index c8359ceba34..5315ddc6a2c 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -1,5 +1,8 @@ +import { EyeIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { ComponentNames } from "../storybook/StoryKit"; import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; +import { InvestigateDotIcon } from "~/assets/icons/InvestigateDotIcon"; +import { WatchDotIcon } from "~/assets/icons/WatchDotIcon"; import { LogoIcon } from "~/components/LogoIcon"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; import { @@ -28,7 +31,9 @@ export default function Story() { return (
- +
Trigger Agent — Icons & Buttons @@ -47,6 +52,9 @@ export default function Story() { Orbit dots + + Action icons + @@ -57,6 +65,9 @@ export default function Story() { + + +
); @@ -254,6 +265,67 @@ function ToggleableMatrix({ ); } +// --- Action icons (current vs dot-matrix) --------------------------------------- + +const ACTION_ICON_SIZES = [16, 20, 24]; + +function ActionIconRow({ + label, + CurrentIcon, + DotIcon, +}: { + label: string; + CurrentIcon: React.ComponentType<{ className?: string; style?: CSSProperties }>; + DotIcon: React.ComponentType<{ className?: string; style?: CSSProperties }>; +}) { + return ( +
+ {label} +
+ {ACTION_ICON_SIZES.map((s) => ( +
+ +
+ current · {s}px +
+
+ ))} + {ACTION_ICON_SIZES.map((s) => ( +
+ +
+ dot-matrix · {s}px +
+
+ ))} +
+
+ ); +} + +/** + * Comparison only: the current heroicons Investigate/Watch icons next to a coarse + * dot-matrix ("LED"/flip-dot) variant, so the owner can pick one. Neither + * `InvestigateDotIcon` nor `WatchDotIcon` is wired into `InvestigateButton.tsx` / + * `WatchButton.tsx` — this tab is the only place they render. + */ +function ActionIconsTab() { + return ( +
+ + The Investigate and Watch action icons, current (heroicons, filled) vs an 8x8 dot-matrix + silhouette in the same style as the logo above. `currentColor`, same box. + + + +
+ ); +} + // --- Logo morph (crisp logo -> orbits) ----------------------------------------- function LogoMorphTab() { From 3e7007aedd63ab456c8dff35cab6852914b2f58e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:31:28 +0000 Subject: [PATCH 12/43] fix(webapp): rounder dot-matrix icons in the ai-agent Shape library Investigate (magnifier + glasses), Watch (eye) and Alert (bell), all generated from circular/elliptical bands instead of hand-drawn corners, with a smaller edge dot to soften the curve. Moved into the Shape library section; dropped the separate Action icons tab. --- apps/webapp/app/assets/icons/AlertDotIcon.tsx | 29 +++++ .../app/assets/icons/InvestigateDotIcon.tsx | 24 ++-- .../icons/InvestigateGlassesDotIcon.tsx | 24 ++++ apps/webapp/app/assets/icons/WatchDotIcon.tsx | 22 ++-- .../webapp/app/assets/icons/dotMatrixIcon.tsx | 24 ++-- .../app/routes/storybook.ai-agent/route.tsx | 108 +++++++----------- 6 files changed, 132 insertions(+), 99 deletions(-) create mode 100644 apps/webapp/app/assets/icons/AlertDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx diff --git a/apps/webapp/app/assets/icons/AlertDotIcon.tsx b/apps/webapp/app/assets/icons/AlertDotIcon.tsx new file mode 100644 index 00000000000..398a5d5c28b --- /dev/null +++ b/apps/webapp/app/assets/icons/AlertDotIcon.tsx @@ -0,0 +1,29 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix ("LED"/flip-dot) variant of an Alert bell icon, for the Investigate/Watch +// comparison set in storybook.ai-agent's shape library: a rounded bell body (circular +// annulus arc, "s" softening the edge) over a flared lip, with a clapper dot. Not used +// anywhere in the app. +const BITMAP = [ + ".....o.....", + "....sss....", + "...os.so...", + "..o.....o..", + "..o.....o..", + "..o.....o..", + "..ss...ss..", + ".ooooooooo.", + ".....o.....", + "....ooo....", + ".....o.....", +]; + +export function AlertDotIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx index 1fd0d952230..19dae370010 100644 --- a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx +++ b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx @@ -1,17 +1,21 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; // Dot-matrix ("LED"/flip-dot) variant of the Investigate action's magnifying-glass icon -// (heroicons `MagnifyingGlassIcon`), for comparison in storybook.ai-agent. 8x8 grid: a -// coarse ring for the lens, a 3-dot diagonal for the handle. Not used anywhere in the app. +// (heroicons `MagnifyingGlassIcon`), for comparison in storybook.ai-agent's shape +// library. 10x10, generated from a true circular annulus (not hand-drawn corners) so +// the ring reads round rather than stair-stepped; "s" softens the band's inner/outer +// edge. Not used anywhere in the app. const BITMAP = [ - ".oooo...", - "o....o..", - "o....o..", - "o....o..", - ".oooo...", - ".....o..", - "......o.", - ".......o", + "...s......", + ".sosso....", + ".o....o...", + "ss....o...", + ".s....o...", + ".o...ss...", + "..ooos....", + ".......o..", + "........o.", + ".........o", ]; export function InvestigateDotIcon({ diff --git a/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx new file mode 100644 index 00000000000..547669c87ef --- /dev/null +++ b/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx @@ -0,0 +1,24 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix ("LED"/flip-dot) spectacles variant of Investigate, for comparison against +// the magnifier in storybook.ai-agent's shape library: two rounded lens rings (circular +// annuli, "s" softening the edge) joined by a short bridge. Not used anywhere in the app. +const BITMAP = [ + "................", + "..sos......sos..", + ".ss..o....ss..o.", + ".o...s.oo.o...s.", + ".o...o....o...o.", + "..osss.....osss.", + "................", +]; + +export function InvestigateGlassesDotIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/WatchDotIcon.tsx b/apps/webapp/app/assets/icons/WatchDotIcon.tsx index acbe3b0bf71..b18cfd112dd 100644 --- a/apps/webapp/app/assets/icons/WatchDotIcon.tsx +++ b/apps/webapp/app/assets/icons/WatchDotIcon.tsx @@ -1,17 +1,19 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; // Dot-matrix ("LED"/flip-dot) variant of the Watch action's eye icon (heroicons -// `EyeIcon`), for comparison in storybook.ai-agent. 8x8 grid: an almond outline with a -// 2x2 pupil block. Not used anywhere in the app. +// `EyeIcon`), for comparison in storybook.ai-agent's shape library. 9x9, generated from +// an elliptical band (not hand-drawn corners) so the almond outline reads round; "s" +// softens the band's inner/outer edge. Not used anywhere in the app. const BITMAP = [ - "........", - "..oooo..", - ".o....o.", - "o..oo..o", - "o..oo..o", - ".o....o.", - "..oooo..", - "........", + ".........", + ".........", + "..sooos..", + ".o..o..o.", + ".o.ooo.o.", + ".o..o..o.", + "..sooos..", + ".........", + ".........", ]; export function WatchDotIcon({ diff --git a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx index f66391aed34..a699f131eb4 100644 --- a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx +++ b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx @@ -1,8 +1,9 @@ /** - * Coarse dot-matrix ("LED"/flip-dot) rendering of an icon silhouette: a bitmap of "o"/"." - * rows becomes a grid of `currentColor` circles at a fixed viewBox, so it drops in - * anywhere a normal icon does. Used by the storybook-only dot-matrix icon variants — - * not wired into any live UI. + * Coarse dot-matrix ("LED"/flip-dot) rendering of an icon silhouette: a bitmap of + * "o"/"s"/"." rows becomes a grid of `currentColor` circles at a fixed viewBox, so it + * drops in anywhere a normal icon does. "s" is a smaller dot — used at the inner/outer + * edge of a curved band so the silhouette reads rounder instead of stair-stepped. Used + * by the storybook-only dot-matrix icon variants — not wired into any live UI. */ export function DotMatrixIcon({ bitmap, @@ -10,14 +11,16 @@ export function DotMatrixIcon({ style, size = 24, dotRadius = 1.15, + smallDotRadius = dotRadius * 0.6, }: { - /** Equal-length rows, "o" = dot on. Read top to bottom, left to right. */ + /** Equal-length rows: "o" = full dot, "s" = small dot, "." = off. */ bitmap: string[]; className?: string; /** `width`/`height` here override `size`, matching how heroicons is usually sized. */ style?: React.CSSProperties; size?: number; dotRadius?: number; + smallDotRadius?: number; }) { const rows = bitmap.length; const cols = bitmap[0]?.length ?? 0; @@ -35,16 +38,17 @@ export function DotMatrixIcon({ xmlns="http://www.w3.org/2000/svg" > {bitmap.flatMap((row, r) => - [...row].map((cell, c) => - cell === "o" ? ( + [...row].map((cell, c) => { + if (cell !== "o" && cell !== "s") return null; + return ( - ) : null - ) + ); + }) )} ); diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 5315ddc6a2c..cacc751bda8 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -1,7 +1,8 @@ -import { EyeIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { ComponentNames } from "../storybook/StoryKit"; import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; +import { AlertDotIcon } from "~/assets/icons/AlertDotIcon"; import { InvestigateDotIcon } from "~/assets/icons/InvestigateDotIcon"; +import { InvestigateGlassesDotIcon } from "~/assets/icons/InvestigateGlassesDotIcon"; import { WatchDotIcon } from "~/assets/icons/WatchDotIcon"; import { LogoIcon } from "~/components/LogoIcon"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; @@ -32,7 +33,13 @@ export default function Story() {
@@ -52,9 +59,6 @@ export default function Story() { Orbit dots - - Action icons - @@ -65,9 +69,6 @@ export default function Story() { - - -
); @@ -191,6 +192,36 @@ function DotMatrixTab() {
))}
+ + Action icon candidates — rounded dot-matrix silhouettes, generated from circular/ elliptical + bands rather than hand-drawn corners. `currentColor`, same box as any other icon. Not wired + into `InvestigateButton.tsx` / `WatchButton.tsx` — comparison only. + +
+
+ +
+ investigate — magnifier +
+
+
+ +
+ investigate — glasses +
+
+
+ +
watch — eye
+
+
+ +
alert — bell
+
+
); } @@ -265,67 +296,6 @@ function ToggleableMatrix({ ); } -// --- Action icons (current vs dot-matrix) --------------------------------------- - -const ACTION_ICON_SIZES = [16, 20, 24]; - -function ActionIconRow({ - label, - CurrentIcon, - DotIcon, -}: { - label: string; - CurrentIcon: React.ComponentType<{ className?: string; style?: CSSProperties }>; - DotIcon: React.ComponentType<{ className?: string; style?: CSSProperties }>; -}) { - return ( -
- {label} -
- {ACTION_ICON_SIZES.map((s) => ( -
- -
- current · {s}px -
-
- ))} - {ACTION_ICON_SIZES.map((s) => ( -
- -
- dot-matrix · {s}px -
-
- ))} -
-
- ); -} - -/** - * Comparison only: the current heroicons Investigate/Watch icons next to a coarse - * dot-matrix ("LED"/flip-dot) variant, so the owner can pick one. Neither - * `InvestigateDotIcon` nor `WatchDotIcon` is wired into `InvestigateButton.tsx` / - * `WatchButton.tsx` — this tab is the only place they render. - */ -function ActionIconsTab() { - return ( -
- - The Investigate and Watch action icons, current (heroicons, filled) vs an 8x8 dot-matrix - silhouette in the same style as the logo above. `currentColor`, same box. - - - -
- ); -} - // --- Logo morph (crisp logo -> orbits) ----------------------------------------- function LogoMorphTab() { From 2825e45cefcfeda38ab3906aea3e2a2791a5ece0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:37:03 +0000 Subject: [PATCH 13/43] fix(webapp): dot-matrix icons drawn on the exact Shape library grid Magnifier, glasses, eye, bell now sit on the same MATRIX x MATRIX grid nodes as DOT_SHAPES (exported dotMatrixGeometry from AgentDotMatrix), same pitch and dot radius, no off-grid or resized dots. Shown Face-options-style: faint full grid behind the lit silhouette. --- apps/webapp/app/assets/icons/AlertDotIcon.tsx | 25 +++------ .../app/assets/icons/InvestigateDotIcon.tsx | 26 +++------- .../icons/InvestigateGlassesDotIcon.tsx | 20 +++----- apps/webapp/app/assets/icons/WatchDotIcon.tsx | 23 +++------ .../webapp/app/assets/icons/dotMatrixIcon.tsx | 51 +++++++++++-------- .../components/primitives/AgentDotMatrix.tsx | 14 ++++- .../app/routes/storybook.ai-agent/route.tsx | 18 ++++--- 7 files changed, 83 insertions(+), 94 deletions(-) diff --git a/apps/webapp/app/assets/icons/AlertDotIcon.tsx b/apps/webapp/app/assets/icons/AlertDotIcon.tsx index 398a5d5c28b..30880dc20c6 100644 --- a/apps/webapp/app/assets/icons/AlertDotIcon.tsx +++ b/apps/webapp/app/assets/icons/AlertDotIcon.tsx @@ -1,29 +1,18 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; -// Dot-matrix ("LED"/flip-dot) variant of an Alert bell icon, for the Investigate/Watch -// comparison set in storybook.ai-agent's shape library: a rounded bell body (circular -// annulus arc, "s" softening the edge) over a flared lip, with a clapper dot. Not used -// anywhere in the app. -const BITMAP = [ - ".....o.....", - "....sss....", - "...os.so...", - "..o.....o..", - "..o.....o..", - "..o.....o..", - "..ss...ss..", - ".ooooooooo.", - ".....o.....", - "....ooo....", - ".....o.....", -]; +// Dot-matrix bell variant for the Investigate/Watch comparison set, drawn on the same +// 5x5 grid as the Shape library: knob, rounded dome sides, flared lip, clapper. For +// comparison in storybook.ai-agent; not used anywhere in the app. +const BITMAP = ["..o..", ".ooo.", "o...o", "ooooo", "..o.."]; export function AlertDotIcon({ className, style, + showGrid, }: { className?: string; style?: React.CSSProperties; + showGrid?: boolean; }) { - return ; + return ; } diff --git a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx index 19dae370010..2719932a4a2 100644 --- a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx +++ b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx @@ -1,29 +1,19 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; -// Dot-matrix ("LED"/flip-dot) variant of the Investigate action's magnifying-glass icon -// (heroicons `MagnifyingGlassIcon`), for comparison in storybook.ai-agent's shape -// library. 10x10, generated from a true circular annulus (not hand-drawn corners) so -// the ring reads round rather than stair-stepped; "s" softens the band's inner/outer -// edge. Not used anywhere in the app. -const BITMAP = [ - "...s......", - ".sosso....", - ".o....o...", - "ss....o...", - ".s....o...", - ".o...ss...", - "..ooos....", - ".......o..", - "........o.", - ".........o", -]; +// Dot-matrix variant of the Investigate action's magnifying-glass icon (heroicons +// `MagnifyingGlassIcon`), drawn on the same 5x5 grid as the Shape library — a rounded +// ring (the "circle" shape) with one corner extended into a handle stub. For comparison +// in storybook.ai-agent; not used anywhere in the app. +const BITMAP = [".ooo.", "o...o", "o...o", "o...o", ".oooo"]; export function InvestigateDotIcon({ className, style, + showGrid, }: { className?: string; style?: React.CSSProperties; + showGrid?: boolean; }) { - return ; + return ; } diff --git a/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx index 547669c87ef..3d4498daeff 100644 --- a/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx +++ b/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx @@ -1,24 +1,18 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; -// Dot-matrix ("LED"/flip-dot) spectacles variant of Investigate, for comparison against -// the magnifier in storybook.ai-agent's shape library: two rounded lens rings (circular -// annuli, "s" softening the edge) joined by a short bridge. Not used anywhere in the app. -const BITMAP = [ - "................", - "..sos......sos..", - ".ss..o....ss..o.", - ".o...s.oo.o...s.", - ".o...o....o...o.", - "..osss.....osss.", - "................", -]; +// Dot-matrix spectacles variant of Investigate, drawn on the same 5x5 grid as the Shape +// library: two lens blocks with a gap for the bridge. For comparison against the +// magnifier in storybook.ai-agent; not used anywhere in the app. +const BITMAP = [".....", "oo.oo", "oo.oo", ".....", "....."]; export function InvestigateGlassesDotIcon({ className, style, + showGrid, }: { className?: string; style?: React.CSSProperties; + showGrid?: boolean; }) { - return ; + return ; } diff --git a/apps/webapp/app/assets/icons/WatchDotIcon.tsx b/apps/webapp/app/assets/icons/WatchDotIcon.tsx index b18cfd112dd..1f5855eefe8 100644 --- a/apps/webapp/app/assets/icons/WatchDotIcon.tsx +++ b/apps/webapp/app/assets/icons/WatchDotIcon.tsx @@ -1,27 +1,18 @@ import { DotMatrixIcon } from "./dotMatrixIcon"; -// Dot-matrix ("LED"/flip-dot) variant of the Watch action's eye icon (heroicons -// `EyeIcon`), for comparison in storybook.ai-agent's shape library. 9x9, generated from -// an elliptical band (not hand-drawn corners) so the almond outline reads round; "s" -// softens the band's inner/outer edge. Not used anywhere in the app. -const BITMAP = [ - ".........", - ".........", - "..sooos..", - ".o..o..o.", - ".o.ooo.o.", - ".o..o..o.", - "..sooos..", - ".........", - ".........", -]; +// Dot-matrix variant of the Watch action's eye icon (heroicons `EyeIcon`), drawn on the +// same 5x5 grid as the Shape library: an outline ring with a pupil dot at center. For +// comparison in storybook.ai-agent; not used anywhere in the app. +const BITMAP = [".....", ".ooo.", "o.o.o", ".ooo.", "....."]; export function WatchDotIcon({ className, style, + showGrid, }: { className?: string; style?: React.CSSProperties; + showGrid?: boolean; }) { - return ; + return ; } diff --git a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx index a699f131eb4..1421a981b64 100644 --- a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx +++ b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx @@ -1,31 +1,32 @@ +import { dotMatrixGeometry, MATRIX } from "~/components/primitives/AgentDotMatrix"; + /** - * Coarse dot-matrix ("LED"/flip-dot) rendering of an icon silhouette: a bitmap of - * "o"/"s"/"." rows becomes a grid of `currentColor` circles at a fixed viewBox, so it - * drops in anywhere a normal icon does. "s" is a smaller dot — used at the inner/outer - * edge of a curved band so the silhouette reads rounder instead of stair-stepped. Used - * by the storybook-only dot-matrix icon variants — not wired into any live UI. + * A silhouette drawn on the exact same grid as the Shape library's `AgentDotMatrix` + * shapes ("Face options" in storybook.ai-agent): `MATRIX`x`MATRIX` nodes, same pitch, + * same dot radius (`dotMatrixGeometry`) — every lit dot sits on a grid node, none + * off-grid, none resized. `currentColor`, so it drops in anywhere a normal icon does. + * Used by the storybook-only dot-matrix icon variants — not wired into any live UI. */ export function DotMatrixIcon({ bitmap, className, style, size = 24, - dotRadius = 1.15, - smallDotRadius = dotRadius * 0.6, + /** Faint always-visible grid, matching "Face options" (`gridAtRest`) at its own default opacity. */ + showGrid = false, + gridOpacity = 0.18, }: { - /** Equal-length rows: "o" = full dot, "s" = small dot, "." = off. */ + /** Exactly `MATRIX` rows of `MATRIX` chars: "o" = lit, "." = off. */ bitmap: string[]; className?: string; /** `width`/`height` here override `size`, matching how heroicons is usually sized. */ style?: React.CSSProperties; size?: number; - dotRadius?: number; - smallDotRadius?: number; + showGrid?: boolean; + gridOpacity?: number; }) { - const rows = bitmap.length; - const cols = bitmap[0]?.length ?? 0; - const cellW = size / cols; - const cellH = size / rows; + const { pitch, dotR } = dotMatrixGeometry(size); + const center = (i: number) => i * pitch + pitch / 2; return ( - {bitmap.flatMap((row, r) => - [...row].map((cell, c) => { - if (cell !== "o" && cell !== "s") return null; + {showGrid && + Array.from({ length: MATRIX * MATRIX }, (_, i) => { + const r = Math.floor(i / MATRIX); + const c = i % MATRIX; return ( ); - }) + })} + {bitmap.flatMap((row, r) => + [...row].map((cell, c) => + cell === "o" ? : null + ) )} ); diff --git a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx index 976d7f25d55..e598f30e8d5 100644 --- a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx +++ b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx @@ -25,7 +25,19 @@ import { useThemeMode } from "~/hooks/useThemeMode"; // into it. The default playlist is sequenced so every consecutive pair of // shapes shares dots. -const MATRIX = 5; +export const MATRIX = 5; + +/** + * The grid geometry every dot in the library shares: a `MATRIX`x`MATRIX` grid, dot + * centered per cell, radius 30% of the cell pitch (never below 0.75px). Static icon + * variants (e.g. `dotMatrixIcon.tsx`) reuse this so they read as native shape-library + * members instead of a different dot system. + */ +export function dotMatrixGeometry(size: number) { + const pitch = size / MATRIX; + const dotR = Math.max(0.75, pitch * 0.3); + return { pitch, dotR }; +} // --- shapes (5-line bitmaps: "o" = dot on) --------------------------------- diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index cacc751bda8..980bebe8c98 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -193,13 +193,18 @@ function DotMatrixTab() { ))}
- Action icon candidates — rounded dot-matrix silhouettes, generated from circular/ elliptical - bands rather than hand-drawn corners. `currentColor`, same box as any other icon. Not wired - into `InvestigateButton.tsx` / `WatchButton.tsx` — comparison only. + Action icon candidates — silhouettes on the exact same grid as the shapes above (same + `dotMatrixGeometry`, grid always visible, same as "Face options"). Every lit dot sits on a + grid node; none off-grid, none resized. `currentColor`, same box as any other icon. Not + wired into `InvestigateButton.tsx` / `WatchButton.tsx` — comparison only.
- +
investigate — magnifier
@@ -208,17 +213,18 @@ function DotMatrixTab() {
investigate — glasses
- +
watch — eye
- +
alert — bell
From 308c7d43507157f57968849d2c6eacd895cb4fa3 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 16:56:08 +0000 Subject: [PATCH 14/43] feat(webapp): five more Investigate dot-matrix icon candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radar, fingerprint, crosshair, flashlight, sonar ping — same true MATRIX bitmap contract as the existing four. Grouped with the magnifier and glasses in the Shape library so all seven compare side by side. --- .../app/assets/icons/CrosshairDotIcon.tsx | 18 +++++++ .../app/assets/icons/FingerprintDotIcon.tsx | 19 +++++++ .../app/assets/icons/FlashlightDotIcon.tsx | 19 +++++++ apps/webapp/app/assets/icons/RadarDotIcon.tsx | 19 +++++++ apps/webapp/app/assets/icons/SonarDotIcon.tsx | 18 +++++++ .../app/routes/storybook.ai-agent/route.tsx | 53 ++++++++++++------- 6 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/app/assets/icons/CrosshairDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/FingerprintDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/FlashlightDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/RadarDotIcon.tsx create mode 100644 apps/webapp/app/assets/icons/SonarDotIcon.tsx diff --git a/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx b/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx new file mode 100644 index 00000000000..954dc6655bb --- /dev/null +++ b/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx @@ -0,0 +1,18 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix crosshair variant of Investigate, drawn on the same 5x5 grid as the Shape +// library: four axis ticks (N/S/E/W) and a center dot, diagonals left empty. For +// comparison in storybook.ai-agent; not used anywhere in the app. +const BITMAP = ["..o..", ".....", "o.o.o", ".....", "..o.."]; + +export function CrosshairDotIcon({ + className, + style, + showGrid, +}: { + className?: string; + style?: React.CSSProperties; + showGrid?: boolean; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx b/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx new file mode 100644 index 00000000000..a4213512698 --- /dev/null +++ b/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx @@ -0,0 +1,19 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix fingerprint variant of Investigate, drawn on the same 5x5 grid as the +// Shape library: gapped, asymmetric arcs curling from top-left to bottom-right, like +// fingerprint ridges. For comparison in storybook.ai-agent; not used anywhere in the +// app. +const BITMAP = [".ooo.", "o....", "o.o..", "o.o.o", ".o.o."]; + +export function FingerprintDotIcon({ + className, + style, + showGrid, +}: { + className?: string; + style?: React.CSSProperties; + showGrid?: boolean; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx b/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx new file mode 100644 index 00000000000..1f888e2345e --- /dev/null +++ b/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx @@ -0,0 +1,19 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix flashlight variant of Investigate, drawn on the same 5x5 grid as the Shape +// library: a solid body block top-left, and a cone of light spreading and thinning +// toward the bottom-right (one fewer dot per row out). For comparison in +// storybook.ai-agent; not used anywhere in the app. +const BITMAP = ["oo...", "oo.o.", ".o.o.", "..o.o", "...o."]; + +export function FlashlightDotIcon({ + className, + style, + showGrid, +}: { + className?: string; + style?: React.CSSProperties; + showGrid?: boolean; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/RadarDotIcon.tsx b/apps/webapp/app/assets/icons/RadarDotIcon.tsx new file mode 100644 index 00000000000..e2d854a8a09 --- /dev/null +++ b/apps/webapp/app/assets/icons/RadarDotIcon.tsx @@ -0,0 +1,19 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix radar variant of Investigate, drawn on the same 5x5 grid as the Shape +// library: a dotted circle, a sweep line from the center, and one blip dot breaking the +// ring's symmetry at the sweep's current position. For comparison in +// storybook.ai-agent; not used anywhere in the app. +const BITMAP = ["..o..", ".o.o.", "o.o.o", ".o.o.", "..o.o"]; + +export function RadarDotIcon({ + className, + style, + showGrid, +}: { + className?: string; + style?: React.CSSProperties; + showGrid?: boolean; +}) { + return ; +} diff --git a/apps/webapp/app/assets/icons/SonarDotIcon.tsx b/apps/webapp/app/assets/icons/SonarDotIcon.tsx new file mode 100644 index 00000000000..216759058ad --- /dev/null +++ b/apps/webapp/app/assets/icons/SonarDotIcon.tsx @@ -0,0 +1,18 @@ +import { DotMatrixIcon } from "./dotMatrixIcon"; + +// Dot-matrix sonar-ping variant of Investigate, drawn on the same 5x5 grid as the Shape +// library: a center ping dot with two symmetric concentric dotted rings expanding +// outward. For comparison in storybook.ai-agent; not used anywhere in the app. +const BITMAP = ["..o..", ".o.o.", "o.o.o", ".o.o.", "..o.."]; + +export function SonarDotIcon({ + className, + style, + showGrid, +}: { + className?: string; + style?: React.CSSProperties; + showGrid?: boolean; +}) { + return ; +} diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 980bebe8c98..cffb5258182 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -1,8 +1,13 @@ import { ComponentNames } from "../storybook/StoryKit"; import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; import { AlertDotIcon } from "~/assets/icons/AlertDotIcon"; +import { CrosshairDotIcon } from "~/assets/icons/CrosshairDotIcon"; +import { FingerprintDotIcon } from "~/assets/icons/FingerprintDotIcon"; +import { FlashlightDotIcon } from "~/assets/icons/FlashlightDotIcon"; import { InvestigateDotIcon } from "~/assets/icons/InvestigateDotIcon"; import { InvestigateGlassesDotIcon } from "~/assets/icons/InvestigateGlassesDotIcon"; +import { RadarDotIcon } from "~/assets/icons/RadarDotIcon"; +import { SonarDotIcon } from "~/assets/icons/SonarDotIcon"; import { WatchDotIcon } from "~/assets/icons/WatchDotIcon"; import { LogoIcon } from "~/components/LogoIcon"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; @@ -37,6 +42,11 @@ export default function Story() { "AgentDotMatrix.tsx", "InvestigateDotIcon.tsx", "InvestigateGlassesDotIcon.tsx", + "RadarDotIcon.tsx", + "FingerprintDotIcon.tsx", + "CrosshairDotIcon.tsx", + "FlashlightDotIcon.tsx", + "SonarDotIcon.tsx", "WatchDotIcon.tsx", "AlertDotIcon.tsx", ]} @@ -76,6 +86,13 @@ export default function Story() { // --- Dot matrix (5x5) --------------------------------------------------------- +/** Any `*DotIcon` component: the shared shape shared by every icon in the candidate lists below. */ +type DotIconComponent = React.ComponentType<{ + className?: string; + style?: CSSProperties; + showGrid?: boolean; +}>; + function DotMatrixTab() { return (
@@ -199,26 +216,24 @@ function DotMatrixTab() { wired into `InvestigateButton.tsx` / `WatchButton.tsx` — comparison only.
-
- -
- investigate — magnifier -
-
-
- -
- investigate — glasses + {( + [ + [InvestigateDotIcon, "investigate — magnifier"], + [InvestigateGlassesDotIcon, "investigate — glasses"], + [RadarDotIcon, "investigate — radar"], + [FingerprintDotIcon, "investigate — fingerprint"], + [CrosshairDotIcon, "investigate — crosshair"], + [FlashlightDotIcon, "investigate — flashlight"], + [SonarDotIcon, "investigate — sonar ping"], + ] as [DotIconComponent, string][] + ).map(([Icon, label]) => ( +
+ +
{label}
-
+ ))} +
+
watch — eye
From 597879d3e3cd1d625c70d05e353c222091821ce8 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 17:02:45 +0000 Subject: [PATCH 15/43] fix(webapp): replace dot-matrix icon candidates with an interactive editor Remove the nine candidate icon files and their story rows. In their place: a clickable MATRIXxMATRIX grid (dotMatrixGeometry, 1.5x the candidates' size) that toggles dots green and reads back as a MATRIX-line bitmap string. Keeps MATRIX/dotMatrixGeometry in AgentDotMatrix.tsx; drops the now-unconsumed static icon renderer. --- apps/webapp/app/assets/icons/AlertDotIcon.tsx | 18 --- .../app/assets/icons/CrosshairDotIcon.tsx | 18 --- .../app/assets/icons/FingerprintDotIcon.tsx | 19 --- .../app/assets/icons/FlashlightDotIcon.tsx | 19 --- .../app/assets/icons/InvestigateDotIcon.tsx | 19 --- .../icons/InvestigateGlassesDotIcon.tsx | 18 --- apps/webapp/app/assets/icons/RadarDotIcon.tsx | 19 --- apps/webapp/app/assets/icons/SonarDotIcon.tsx | 18 --- apps/webapp/app/assets/icons/WatchDotIcon.tsx | 18 --- .../webapp/app/assets/icons/dotMatrixIcon.tsx | 62 --------- .../app/routes/storybook.ai-agent/route.tsx | 121 +++++++++--------- 11 files changed, 61 insertions(+), 288 deletions(-) delete mode 100644 apps/webapp/app/assets/icons/AlertDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/CrosshairDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/FingerprintDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/FlashlightDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/InvestigateDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/RadarDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/SonarDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/WatchDotIcon.tsx delete mode 100644 apps/webapp/app/assets/icons/dotMatrixIcon.tsx diff --git a/apps/webapp/app/assets/icons/AlertDotIcon.tsx b/apps/webapp/app/assets/icons/AlertDotIcon.tsx deleted file mode 100644 index 30880dc20c6..00000000000 --- a/apps/webapp/app/assets/icons/AlertDotIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix bell variant for the Investigate/Watch comparison set, drawn on the same -// 5x5 grid as the Shape library: knob, rounded dome sides, flared lip, clapper. For -// comparison in storybook.ai-agent; not used anywhere in the app. -const BITMAP = ["..o..", ".ooo.", "o...o", "ooooo", "..o.."]; - -export function AlertDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx b/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx deleted file mode 100644 index 954dc6655bb..00000000000 --- a/apps/webapp/app/assets/icons/CrosshairDotIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix crosshair variant of Investigate, drawn on the same 5x5 grid as the Shape -// library: four axis ticks (N/S/E/W) and a center dot, diagonals left empty. For -// comparison in storybook.ai-agent; not used anywhere in the app. -const BITMAP = ["..o..", ".....", "o.o.o", ".....", "..o.."]; - -export function CrosshairDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx b/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx deleted file mode 100644 index a4213512698..00000000000 --- a/apps/webapp/app/assets/icons/FingerprintDotIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix fingerprint variant of Investigate, drawn on the same 5x5 grid as the -// Shape library: gapped, asymmetric arcs curling from top-left to bottom-right, like -// fingerprint ridges. For comparison in storybook.ai-agent; not used anywhere in the -// app. -const BITMAP = [".ooo.", "o....", "o.o..", "o.o.o", ".o.o."]; - -export function FingerprintDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx b/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx deleted file mode 100644 index 1f888e2345e..00000000000 --- a/apps/webapp/app/assets/icons/FlashlightDotIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix flashlight variant of Investigate, drawn on the same 5x5 grid as the Shape -// library: a solid body block top-left, and a cone of light spreading and thinning -// toward the bottom-right (one fewer dot per row out). For comparison in -// storybook.ai-agent; not used anywhere in the app. -const BITMAP = ["oo...", "oo.o.", ".o.o.", "..o.o", "...o."]; - -export function FlashlightDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx deleted file mode 100644 index 2719932a4a2..00000000000 --- a/apps/webapp/app/assets/icons/InvestigateDotIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix variant of the Investigate action's magnifying-glass icon (heroicons -// `MagnifyingGlassIcon`), drawn on the same 5x5 grid as the Shape library — a rounded -// ring (the "circle" shape) with one corner extended into a handle stub. For comparison -// in storybook.ai-agent; not used anywhere in the app. -const BITMAP = [".ooo.", "o...o", "o...o", "o...o", ".oooo"]; - -export function InvestigateDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx b/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx deleted file mode 100644 index 3d4498daeff..00000000000 --- a/apps/webapp/app/assets/icons/InvestigateGlassesDotIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix spectacles variant of Investigate, drawn on the same 5x5 grid as the Shape -// library: two lens blocks with a gap for the bridge. For comparison against the -// magnifier in storybook.ai-agent; not used anywhere in the app. -const BITMAP = [".....", "oo.oo", "oo.oo", ".....", "....."]; - -export function InvestigateGlassesDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/RadarDotIcon.tsx b/apps/webapp/app/assets/icons/RadarDotIcon.tsx deleted file mode 100644 index e2d854a8a09..00000000000 --- a/apps/webapp/app/assets/icons/RadarDotIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix radar variant of Investigate, drawn on the same 5x5 grid as the Shape -// library: a dotted circle, a sweep line from the center, and one blip dot breaking the -// ring's symmetry at the sweep's current position. For comparison in -// storybook.ai-agent; not used anywhere in the app. -const BITMAP = ["..o..", ".o.o.", "o.o.o", ".o.o.", "..o.o"]; - -export function RadarDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/SonarDotIcon.tsx b/apps/webapp/app/assets/icons/SonarDotIcon.tsx deleted file mode 100644 index 216759058ad..00000000000 --- a/apps/webapp/app/assets/icons/SonarDotIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix sonar-ping variant of Investigate, drawn on the same 5x5 grid as the Shape -// library: a center ping dot with two symmetric concentric dotted rings expanding -// outward. For comparison in storybook.ai-agent; not used anywhere in the app. -const BITMAP = ["..o..", ".o.o.", "o.o.o", ".o.o.", "..o.."]; - -export function SonarDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/WatchDotIcon.tsx b/apps/webapp/app/assets/icons/WatchDotIcon.tsx deleted file mode 100644 index 1f5855eefe8..00000000000 --- a/apps/webapp/app/assets/icons/WatchDotIcon.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DotMatrixIcon } from "./dotMatrixIcon"; - -// Dot-matrix variant of the Watch action's eye icon (heroicons `EyeIcon`), drawn on the -// same 5x5 grid as the Shape library: an outline ring with a pupil dot at center. For -// comparison in storybook.ai-agent; not used anywhere in the app. -const BITMAP = [".....", ".ooo.", "o.o.o", ".ooo.", "....."]; - -export function WatchDotIcon({ - className, - style, - showGrid, -}: { - className?: string; - style?: React.CSSProperties; - showGrid?: boolean; -}) { - return ; -} diff --git a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx b/apps/webapp/app/assets/icons/dotMatrixIcon.tsx deleted file mode 100644 index 1421a981b64..00000000000 --- a/apps/webapp/app/assets/icons/dotMatrixIcon.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { dotMatrixGeometry, MATRIX } from "~/components/primitives/AgentDotMatrix"; - -/** - * A silhouette drawn on the exact same grid as the Shape library's `AgentDotMatrix` - * shapes ("Face options" in storybook.ai-agent): `MATRIX`x`MATRIX` nodes, same pitch, - * same dot radius (`dotMatrixGeometry`) — every lit dot sits on a grid node, none - * off-grid, none resized. `currentColor`, so it drops in anywhere a normal icon does. - * Used by the storybook-only dot-matrix icon variants — not wired into any live UI. - */ -export function DotMatrixIcon({ - bitmap, - className, - style, - size = 24, - /** Faint always-visible grid, matching "Face options" (`gridAtRest`) at its own default opacity. */ - showGrid = false, - gridOpacity = 0.18, -}: { - /** Exactly `MATRIX` rows of `MATRIX` chars: "o" = lit, "." = off. */ - bitmap: string[]; - className?: string; - /** `width`/`height` here override `size`, matching how heroicons is usually sized. */ - style?: React.CSSProperties; - size?: number; - showGrid?: boolean; - gridOpacity?: number; -}) { - const { pitch, dotR } = dotMatrixGeometry(size); - const center = (i: number) => i * pitch + pitch / 2; - - return ( - - {showGrid && - Array.from({ length: MATRIX * MATRIX }, (_, i) => { - const r = Math.floor(i / MATRIX); - const c = i % MATRIX; - return ( - - ); - })} - {bitmap.flatMap((row, r) => - [...row].map((cell, c) => - cell === "o" ? : null - ) - )} - - ); -} diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index cffb5258182..a30c13e4ca4 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -1,14 +1,5 @@ import { ComponentNames } from "../storybook/StoryKit"; import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react"; -import { AlertDotIcon } from "~/assets/icons/AlertDotIcon"; -import { CrosshairDotIcon } from "~/assets/icons/CrosshairDotIcon"; -import { FingerprintDotIcon } from "~/assets/icons/FingerprintDotIcon"; -import { FlashlightDotIcon } from "~/assets/icons/FlashlightDotIcon"; -import { InvestigateDotIcon } from "~/assets/icons/InvestigateDotIcon"; -import { InvestigateGlassesDotIcon } from "~/assets/icons/InvestigateGlassesDotIcon"; -import { RadarDotIcon } from "~/assets/icons/RadarDotIcon"; -import { SonarDotIcon } from "~/assets/icons/SonarDotIcon"; -import { WatchDotIcon } from "~/assets/icons/WatchDotIcon"; import { LogoIcon } from "~/components/LogoIcon"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; import { @@ -22,13 +13,16 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { AgentDotMatrix, AgentMonoLogo, + dotMatrixGeometry, DOT_MATRIX_PALETTES, DOT_SHAPES, EXTRA_FACE_SHAPES, FACE_SHAPES, + MATRIX, type DotMatrixPaletteName, type DotShapeName, } from "~/components/primitives/AgentDotMatrix"; +import { cn } from "~/utils/cn"; // Experiments for the trigger.dev AI dashboard-agent identity: a resting logo // that animates while the agent thinks. Each tab is a separate experiment. @@ -37,20 +31,7 @@ export default function Story() { return (
- +
Trigger Agent — Icons & Buttons @@ -86,13 +67,6 @@ export default function Story() { // --- Dot matrix (5x5) --------------------------------------------------------- -/** Any `*DotIcon` component: the shared shape shared by every icon in the candidate lists below. */ -type DotIconComponent = React.ComponentType<{ - className?: string; - style?: CSSProperties; - showGrid?: boolean; -}>; - function DotMatrixTab() { return (
@@ -210,43 +184,70 @@ function DotMatrixTab() { ))}
- Action icon candidates — silhouettes on the exact same grid as the shapes above (same - `dotMatrixGeometry`, grid always visible, same as "Face options"). Every lit dot sits on a - grid node; none off-grid, none resized. `currentColor`, same box as any other icon. Not - wired into `InvestigateButton.tsx` / `WatchButton.tsx` — comparison only. + Icon editor — same grid as the shapes above (`dotMatrixGeometry`, `MATRIX`x`MATRIX`), at + 1.5x the size the candidates were shown at. Click a dot to toggle it; the bitmap below reads + back as a `MATRIX`-line string, ready to paste into a shape definition.
- {( - [ - [InvestigateDotIcon, "investigate — magnifier"], - [InvestigateGlassesDotIcon, "investigate — glasses"], - [RadarDotIcon, "investigate — radar"], - [FingerprintDotIcon, "investigate — fingerprint"], - [CrosshairDotIcon, "investigate — crosshair"], - [FlashlightDotIcon, "investigate — flashlight"], - [SonarDotIcon, "investigate — sonar ping"], - ] as [DotIconComponent, string][] - ).map(([Icon, label]) => ( -
- -
{label}
-
- ))} -
-
-
- -
watch — eye
-
-
- -
alert — bell
-
+
); } +// 1.5x the 32px candidate icons this replaced. +const EDITOR_SIZE = 48; + +/** Interactive `MATRIX`x`MATRIX` grid: click a dot to toggle it on (accent) or off (ghost). */ +function DotGridEditor() { + const [lit, setLit] = useState(() => new Array(MATRIX * MATRIX).fill(false)); + const { pitch, dotR } = dotMatrixGeometry(EDITOR_SIZE); + const center = (i: number) => i * pitch + pitch / 2; + + const toggle = (index: number) => { + setLit((current) => current.map((value, i) => (i === index ? !value : value))); + }; + + const rows = useMemo( + () => + Array.from({ length: MATRIX }, (_, r) => + Array.from({ length: MATRIX }, (_, c) => (lit[r * MATRIX + c] ? "#" : ".")).join("") + ), + [lit] + ); + + return ( +
+ + {lit.map((isLit, i) => { + const r = Math.floor(i / MATRIX); + const c = i % MATRIX; + return ( + toggle(i)} + /> + ); + })} + +
+        {rows.join("\n")}
+      
+
+ ); +} + function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { const [active, setActive] = useState(false); const timeout = useRef>(); From cb9f4785b662246136cd6d461800ed09e720bdcb Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 17:27:41 +0000 Subject: [PATCH 16/43] feat(webapp): render the owner's 10 drawn dot-matrix icon bitmaps One generic DotGrid component (shared with the editor) renders each as a numbered row, editor size and 20px, ghost-grid style. #2/#5 are an intentional duplicate pair. --- .../app/routes/storybook.ai-agent/route.tsx | 115 ++++++++++++++---- 1 file changed, 90 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index a30c13e4ca4..28fc03c4bec 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -191,18 +191,69 @@ function DotMatrixTab() {
+ + Owner-drawn candidates, exactly as given — editor size and {CANDIDATE_SMALL_SIZE}px, so + legibility at icon size is judgeable. #2 and #5 are identical (an A/B pair). + +
); } // 1.5x the 32px candidate icons this replaced. const EDITOR_SIZE = 48; +// Within the 16-24px range action icons render at. +const CANDIDATE_SMALL_SIZE = 20; + +/** + * Renders a flat `MATRIX * MATRIX` lit/unlit array on the shared grid geometry — + * `dotMatrixGeometry`, so pitch and dot radius always match the Shape library above. + * Shared by the interactive editor (`onToggle`) and the static owner-bitmap previews. + */ +function DotGrid({ + lit, + size, + litClassName = "text-success", + onToggle, +}: { + lit: boolean[]; + size: number; + litClassName?: string; + onToggle?: (index: number) => void; +}) { + const { pitch, dotR } = dotMatrixGeometry(size); + const center = (i: number) => i * pitch + pitch / 2; + + return ( + + {lit.map((isLit, i) => { + const r = Math.floor(i / MATRIX); + const c = i % MATRIX; + return ( + onToggle(i) : undefined} + /> + ); + })} + + ); +} /** Interactive `MATRIX`x`MATRIX` grid: click a dot to toggle it on (accent) or off (ghost). */ function DotGridEditor() { const [lit, setLit] = useState(() => new Array(MATRIX * MATRIX).fill(false)); - const { pitch, dotR } = dotMatrixGeometry(EDITOR_SIZE); - const center = (i: number) => i * pitch + pitch / 2; const toggle = (index: number) => { setLit((current) => current.map((value, i) => (i === index ? !value : value))); @@ -218,29 +269,7 @@ function DotGridEditor() { return (
- - {lit.map((isLit, i) => { - const r = Math.floor(i / MATRIX); - const c = i % MATRIX; - return ( - toggle(i)} - /> - ); - })} - +
         {rows.join("\n")}
       
@@ -248,6 +277,42 @@ function DotGridEditor() { ); } +function bitmapToLit(bitmap: string[]): boolean[] { + return bitmap.flatMap((row) => [...row].map((cell) => cell === "#")); +} + +// Owner-drawn in the editor above, 5-line `MATRIX` bitmaps, exactly as given. +const OWNER_BITMAPS: { id: number; bitmap: string[] }[] = [ + { id: 1, bitmap: ["#.#.#", ".#.#.", "#...#", ".#.#.", "#.#.#"] }, + { id: 2, bitmap: [".###.", "#####", "#.#.#", ".###.", "....."] }, + { id: 3, bitmap: [".....", ".###.", "#.#.#", ".###.", "....."] }, + { id: 4, bitmap: ["..#..", ".###.", "##.##", ".###.", "..#.."] }, + // Same as #2 — the owner may be A/B-ing it; rendered anyway. + { id: 5, bitmap: [".###.", "#####", "#.#.#", ".###.", "....."] }, + { id: 6, bitmap: [".###.", "#.#.#", "#####", "#.#.#", ".###."] }, + { id: 7, bitmap: ["..#..", "..#..", "##.##", "..#..", "..#.."] }, + { id: 8, bitmap: [".###.", "#...#", "#...#", "#####", "..#.."] }, + { id: 9, bitmap: [".###.", "#####", "#.#.#", "#####", "..#.."] }, + { id: 10, bitmap: [".###.", "#####", "#####", "..#..", "..#.."] }, +]; + +function OwnerBitmapsRow() { + return ( +
+ {OWNER_BITMAPS.map(({ id, bitmap }) => { + const lit = bitmapToLit(bitmap); + return ( +
+ + +
#{id}
+
+ ); + })} +
+ ); +} + function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { const [active, setActive] = useState(false); const timeout = useRef>(); From 1b644e723377651fcfb00114891dbd9819f89ff9 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 17:37:53 +0000 Subject: [PATCH 17/43] fix(webapp): Investigate/Watch surfaces use the Ask Trigger glyph; drop editor's owner-bitmap row Every feature surface (buttons, suggested prompts, chat history status, watch card, watch result block) now renders AgentMonoLogo instead of the heroicons magnifier/eye, matching the Ask Trigger nav button. Unrelated magnifiers/eyes (search inputs, admin search, password-visibility toggles) are untouched. storybook.ai-agent: remove the owner-drawn candidates row and the editor's description text; the interactive editor stays. --- .../dashboard-agent/DashboardAgentHistory.tsx | 5 +- .../DashboardAgentSuggestedPrompts.tsx | 7 ++- .../dashboard-agent/InvestigateButton.tsx | 5 +- .../dashboard-agent/WatchButton.tsx | 5 +- .../components/dashboard-agent/WatchCard.tsx | 4 +- .../dashboard-agent/WatchResultBlock.tsx | 26 +++++----- .../app/routes/storybook.ai-agent/route.tsx | 48 ------------------- 7 files changed, 27 insertions(+), 73 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx index 3bc84c16938..424d17bd581 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx @@ -1,5 +1,6 @@ -import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { TrashIcon } from "@heroicons/react/20/solid"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -42,7 +43,7 @@ function ProcessIcon({ process }: { process: ChatProcess }) { return ( {process === "investigating" ? ( - + ) : ( )} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx index 4892352356f..6045c4476c7 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx @@ -1,13 +1,12 @@ import { BookOpenIcon, ChartBarIcon, - EyeIcon, - MagnifyingGlassIcon, QuestionMarkCircleIcon, SparklesIcon, } from "@heroicons/react/20/solid"; import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; import { useMemo, useState } from "react"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; import type { RenderIcon } from "~/components/primitives/Icon"; import { @@ -20,8 +19,8 @@ import { const PROMPT_SLOT_BUTTON: Record = { promoted: { variant: "primary/small", icon: SparklesIcon }, - investigate: { variant: "primary/small", icon: MagnifyingGlassIcon }, - watch: { variant: "secondary/small", icon: EyeIcon }, + investigate: { variant: "primary/small", icon: }, + watch: { variant: "secondary/small", icon: }, status: { variant: "secondary/small", icon: ChartBarIcon }, explain: { variant: "tertiary/small", icon: QuestionMarkCircleIcon }, docs: { variant: "docs/small", icon: BookOpenIcon }, diff --git a/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx b/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx index f109941af5d..b134b60844c 100644 --- a/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx +++ b/apps/webapp/app/components/dashboard-agent/InvestigateButton.tsx @@ -1,4 +1,4 @@ -import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { useDashboardAgent } from "./dashboardAgentLauncher"; @@ -30,8 +30,7 @@ export function InvestigateButton({
))}
- - Icon editor — same grid as the shapes above (`dotMatrixGeometry`, `MATRIX`x`MATRIX`), at - 1.5x the size the candidates were shown at. Click a dot to toggle it; the bitmap below reads - back as a `MATRIX`-line string, ready to paste into a shape definition. -
- - Owner-drawn candidates, exactly as given — editor size and {CANDIDATE_SMALL_SIZE}px, so - legibility at icon size is judgeable. #2 and #5 are identical (an A/B pair). - -
); } // 1.5x the 32px candidate icons this replaced. const EDITOR_SIZE = 48; -// Within the 16-24px range action icons render at. -const CANDIDATE_SMALL_SIZE = 20; /** * Renders a flat `MATRIX * MATRIX` lit/unlit array on the shared grid geometry — @@ -277,42 +265,6 @@ function DotGridEditor() { ); } -function bitmapToLit(bitmap: string[]): boolean[] { - return bitmap.flatMap((row) => [...row].map((cell) => cell === "#")); -} - -// Owner-drawn in the editor above, 5-line `MATRIX` bitmaps, exactly as given. -const OWNER_BITMAPS: { id: number; bitmap: string[] }[] = [ - { id: 1, bitmap: ["#.#.#", ".#.#.", "#...#", ".#.#.", "#.#.#"] }, - { id: 2, bitmap: [".###.", "#####", "#.#.#", ".###.", "....."] }, - { id: 3, bitmap: [".....", ".###.", "#.#.#", ".###.", "....."] }, - { id: 4, bitmap: ["..#..", ".###.", "##.##", ".###.", "..#.."] }, - // Same as #2 — the owner may be A/B-ing it; rendered anyway. - { id: 5, bitmap: [".###.", "#####", "#.#.#", ".###.", "....."] }, - { id: 6, bitmap: [".###.", "#.#.#", "#####", "#.#.#", ".###."] }, - { id: 7, bitmap: ["..#..", "..#..", "##.##", "..#..", "..#.."] }, - { id: 8, bitmap: [".###.", "#...#", "#...#", "#####", "..#.."] }, - { id: 9, bitmap: [".###.", "#####", "#.#.#", "#####", "..#.."] }, - { id: 10, bitmap: [".###.", "#####", "#####", "..#..", "..#.."] }, -]; - -function OwnerBitmapsRow() { - return ( -
- {OWNER_BITMAPS.map(({ id, bitmap }) => { - const lit = bitmapToLit(bitmap); - return ( -
- - -
#{id}
-
- ); - })} -
- ); -} - function AskTriggerButton({ variant, matrixSize }: { variant: ButtonVariant; matrixSize: number }) { const [active, setActive] = useState(false); const timeout = useRef>(); From aed73d567594d853c96499ff8ae1760b418b9ade Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 17:44:18 +0000 Subject: [PATCH 18/43] fix(webapp): live watch result block uses the chat spinner, not a static glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WatchResultBlock's "watching" outcome (a watch just created and still live) now shows AgentSpinner — the same animated dot-matrix spinner the chat uses while thinking, and what WatchChips already shows for an active watch. The one-shot terminal outcomes keep their icons. --- .../components/dashboard-agent/WatchResultBlock.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx index bb9f208c8e1..309f674f3ee 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx @@ -12,18 +12,18 @@ */ import { CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import type { WatchResultBlock as WatchResultBlockPayload } from "@internal/dashboard-agent-contracts"; -import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; +import { AgentSpinner } from "~/components/primitives/Spinner"; import { ChatSystemBlock } from "./chat-layout"; import { TONE_ICON_COLOR } from "./agent-badges"; import { cn } from "~/utils/cn"; /** - * Icon and label per outcome. A confirmation is not a success (nothing has happened - * yet) so it wears the neutral Ask Trigger glyph; the check belongs to the one-shot - * that did answer the question. + * Icon and label per outcome. `watching` is a live watch, still running — same + * spinner the chat uses while the agent is responding, not a static glyph. The + * one-shot outcomes are terminal (nothing left to watch), so they keep their icons. */ const OUTCOME = { - watching: { label: "Watch", icon: }, + watching: { label: "Watch", icon: }, already_true: { label: "Watch", icon: , From 73d0dde7ab932f51c12ddb36dfd08acfbeeaa8f7 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 17:56:36 +0000 Subject: [PATCH 19/43] fix(webapp): revert Investigate/Watch icons to original inside the chat panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ask Trigger glyph swap stays only outside the chat window (InvestigateButton, WatchButton — page CTAs that open the chat). Surfaces rendered inside the panel tree revert to their original icons: suggested-prompt chips, chat history's investigating status, and the watch draft card header. The active-watch spinner in WatchResultBlock is untouched. --- .../components/dashboard-agent/DashboardAgentHistory.tsx | 5 ++--- .../dashboard-agent/DashboardAgentSuggestedPrompts.tsx | 7 ++++--- apps/webapp/app/components/dashboard-agent/WatchCard.tsx | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx index 424d17bd581..3bc84c16938 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx @@ -1,6 +1,5 @@ -import { TrashIcon } from "@heroicons/react/20/solid"; +import { MagnifyingGlassIcon, TrashIcon } from "@heroicons/react/20/solid"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3/utils/durations"; -import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -43,7 +42,7 @@ function ProcessIcon({ process }: { process: ChatProcess }) { return ( {process === "investigating" ? ( - + ) : ( )} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx index 6045c4476c7..4892352356f 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentSuggestedPrompts.tsx @@ -1,12 +1,13 @@ import { BookOpenIcon, ChartBarIcon, + EyeIcon, + MagnifyingGlassIcon, QuestionMarkCircleIcon, SparklesIcon, } from "@heroicons/react/20/solid"; import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; import { useMemo, useState } from "react"; -import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; import type { RenderIcon } from "~/components/primitives/Icon"; import { @@ -19,8 +20,8 @@ import { const PROMPT_SLOT_BUTTON: Record = { promoted: { variant: "primary/small", icon: SparklesIcon }, - investigate: { variant: "primary/small", icon: }, - watch: { variant: "secondary/small", icon: }, + investigate: { variant: "primary/small", icon: MagnifyingGlassIcon }, + watch: { variant: "secondary/small", icon: EyeIcon }, status: { variant: "secondary/small", icon: ChartBarIcon }, explain: { variant: "tertiary/small", icon: QuestionMarkCircleIcon }, docs: { variant: "docs/small", icon: BookOpenIcon }, diff --git a/apps/webapp/app/components/dashboard-agent/WatchCard.tsx b/apps/webapp/app/components/dashboard-agent/WatchCard.tsx index 365a47ee2f3..e395dcecf10 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchCard.tsx @@ -10,6 +10,7 @@ * Pure component: draft in, markup and callbacks out. Draft rules live in * `watch-card.ts` and wording in `app/presenters/v3/dashboardAgent`. */ +import { EyeIcon } from "@heroicons/react/20/solid"; import { WATCH_WINDOW_HOURS_OPTIONS, watchCadenceOptions, @@ -17,7 +18,6 @@ import { type WatchKind, } from "@internal/dashboard-agent-contracts"; import { useId, useState } from "react"; -import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { Checkbox } from "~/components/primitives/Checkbox"; import { Input } from "~/components/primitives/Input"; @@ -167,7 +167,7 @@ export function WatchCard({ return ( } + icon={} actions={ <> {/* One confirm, expanded or not: an expanded card is submitted as shown. */} From 4474a858caf59e9743e3bd9919c0ddd387a6dfe3 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 18:32:17 +0000 Subject: [PATCH 20/43] docs(webapp): add server-changes note for the floating chat window --- .server-changes/floating-chat-window.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/floating-chat-window.md diff --git a/.server-changes/floating-chat-window.md b/.server-changes/floating-chat-window.md new file mode 100644 index 00000000000..b7e32aeb9ed --- /dev/null +++ b/.server-changes/floating-chat-window.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Ask Trigger now opens as a floating window at the bottom of the page that you can drag anywhere and resize. The Ask Trigger button toggles the chat open and closed. From 144767470e31fde43a2b2d62cc988ee2597a82f6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 18:54:25 +0000 Subject: [PATCH 21/43] fix(webapp): trim verbose comments; drag/resize gate fixes; kill duplicate floating-window demo Comment sweep: cap every survivor at two sentences, drop what-comments and a stale dotMatrixIcon.tsx reference, across panel-layout, DraggableResizable, draggableResizableMath, WatchResultBlock and the storybook routes. Gate fixes from cross-model review: - resizeRect: minSize now wins over a viewport-derived cap smaller than it, for all four resize directions (+4 tests). - Header drag no longer engages when a gesture starts on a button/ link/input inside it. - Fullscreen (no dragHandleProps.onPan) no longer applies drag cursor classes or local pan-state handlers. - Ask Trigger's "Close chat" tooltip drops the new-chat shortcut key. - Resize handles no longer get clipped by the window's overflow-hidden (moved to an inner content wrapper). - Drag handle gets touch-action:none so touch drags don't fight scrolling. - Dot-grid editor dots are keyboard-operable (role=button, Enter/ Space toggles). - storybook.dashboard-agent-floating: mount-gates the demo window (avoids an SSR/client position mismatch) and resets fullscreen on every close path. - New panel-layout.dom.test.ts: initialFloatingRect against a stubbed viewport, and a hook-level render proving the rect (and the minSize fix) survive the real wiring. Remove the "Floating window" section from storybook.agent-ui: the standalone storybook.dashboard-agent-floating route is the one floating-window demo going forward. --- .../dashboard-agent/DashboardAgentPanel.tsx | 52 ++++++++-- .../dashboard-agent/WatchResultBlock.tsx | 6 +- .../dashboardAgentLauncher.tsx | 12 ++- .../dashboard-agent/panel-layout.dom.test.ts | 97 +++++++++++++++++++ .../dashboard-agent/panel-layout.tsx | 25 +++-- .../components/primitives/AgentDotMatrix.tsx | 7 +- .../primitives/DraggableResizable.dom.test.ts | 13 +-- .../primitives/DraggableResizable.tsx | 10 +- .../primitives/draggableResizableMath.test.ts | 46 ++++++--- .../primitives/draggableResizableMath.ts | 31 +++--- .../app/routes/storybook.agent-ui/manifest.ts | 8 -- .../app/routes/storybook.agent-ui/route.tsx | 84 +--------------- .../app/routes/storybook.ai-agent/route.tsx | 24 +++-- .../route.tsx | 27 ++++-- 14 files changed, 256 insertions(+), 186 deletions(-) create mode 100644 apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx index 462d1dd16f5..88a5405ae24 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx @@ -135,6 +135,10 @@ export function DashboardAgentPanel({ ); // Cursor feedback only; the drag itself is handled by `dragHandleProps`. const [draggingWindow, setDraggingWindow] = useState(false); + // Fullscreen passes no drag handlers at all (an empty object), so pan wiring is a no-op there. + const isDraggable = !!dragHandleProps?.onPan; + // Set when a gesture starts on a header button/link, so its onPan steps are dropped too. + const ignoringGesture = useRef(false); const currentPage = agentPageLabel(pageContext, location.pathname); @@ -614,15 +618,45 @@ export function DashboardAgentPanel({ > { - setDraggingWindow(true); - dragHandleProps?.onPanStart?.(event, info); - }} - onPanEnd={(event, info) => { - setDraggingWindow(false); - dragHandleProps?.onPanEnd?.(event, info); - }} - className={cn("select-none", draggingWindow ? "cursor-grabbing" : "cursor-grab")} + onPanStart={ + isDraggable + ? (event, info) => { + // Buttons/links inside the header (history, new chat, expand, close) sit + // above the drag handle; a click there must not move the window. + if ( + (event.target as HTMLElement | null)?.closest("button, a, input, [role=button]") + ) { + ignoringGesture.current = true; + return; + } + ignoringGesture.current = false; + setDraggingWindow(true); + dragHandleProps?.onPanStart?.(event, info); + } + : undefined + } + onPan={ + isDraggable + ? (event, info) => { + if (ignoringGesture.current) return; + dragHandleProps?.onPan?.(event, info); + } + : undefined + } + onPanEnd={ + isDraggable + ? (event, info) => { + ignoringGesture.current = false; + setDraggingWindow(false); + dragHandleProps?.onPanEnd?.(event, info); + } + : undefined + } + className={cn( + "select-none", + isDraggable && "touch-none", + isDraggable && (draggingWindow ? "cursor-grabbing" : "cursor-grab") + )} > }, already_true: { diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index d3fb7bf1f01..64a0379f784 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -56,10 +56,14 @@ export function DashboardAgentLauncher() { tabbable disableHoverableContent content={ - - {open ? "Close chat" : "Open chat"} - - + open ? ( + "Close chat" + ) : ( + + Open chat + + + ) } button={ diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts b/apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts new file mode 100644 index 00000000000..ea8adddffb8 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it } from "vitest"; +import type { PanInfo } from "framer-motion"; +import { useDraggableResizable } from "~/components/primitives/DraggableResizable"; +import { + FLOATING_HEIGHT, + FLOATING_MARGIN, + FLOATING_MIN_SIZE, + FLOATING_WIDTH, + initialFloatingRect, +} from "./panel-layout"; + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + } + container?.remove(); + container = undefined; + root = undefined; +}); + +function stubViewport(width: number, height: number) { + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: height, configurable: true }); +} + +describe("initialFloatingRect", () => { + it("docks bottom-right, sized to FLOATING_WIDTH/HEIGHT, padded by FLOATING_MARGIN", () => { + stubViewport(1200, 900); + expect(initialFloatingRect()).toEqual({ + x: 1200 - FLOATING_WIDTH - FLOATING_MARGIN, + y: 900 - FLOATING_HEIGHT - FLOATING_MARGIN, + w: FLOATING_WIDTH, + h: FLOATING_HEIGHT, + }); + }); +}); + +// Same render-hook pattern as DraggableResizable.dom.test.ts. +function renderDraggableResizable() { + let latest!: ReturnType; + function Harness() { + // oxlint-disable-next-line react/globals -- test harness capturing the hook's return value. + latest = useDraggableResizable({ + initial: initialFloatingRect(), + minSize: FLOATING_MIN_SIZE, + viewportPadding: FLOATING_MARGIN, + }); + return null; + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness)); + }); + return { + get current() { + return latest; + }, + }; +} + +const fakeEvent = {} as PointerEvent; +function fakePanInfo(dx: number, dy: number): PanInfo { + return { + delta: { x: dx, y: dy }, + offset: { x: dx, y: dy }, + point: { x: 0, y: 0 }, + velocity: { x: 0, y: 0 }, + }; +} + +describe("the floating window's rect, wired with panel-layout's own constants", () => { + it("renders at initialFloatingRect's position and size", () => { + stubViewport(1200, 900); + const hook = renderDraggableResizable(); + expect(hook.current.position).toEqual({ + x: 1200 - FLOATING_WIDTH - FLOATING_MARGIN, + y: 900 - FLOATING_HEIGHT - FLOATING_MARGIN, + }); + expect(hook.current.size).toEqual({ w: FLOATING_WIDTH, h: FLOATING_HEIGHT }); + }); + + it("never shrinks below FLOATING_MIN_SIZE even against a viewport smaller than it", () => { + stubViewport(300, 300); + const hook = renderDraggableResizable(); + act(() => hook.current.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(0, 0))); + expect(hook.current.size.w).toBe(FLOATING_MIN_SIZE.w); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index 11eca0e46cb..b391d9cb006 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -13,13 +13,13 @@ import { cn } from "~/utils/cn"; const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen"; // V1 floating window: 380x512, bottom-right, matching the gallery's own panel frame. -const FLOATING_WIDTH = 380; -const FLOATING_HEIGHT = 512; -const FLOATING_MARGIN = 16; -const FLOATING_MIN_SIZE = { w: 320, h: 360 }; +export const FLOATING_WIDTH = 380; +export const FLOATING_HEIGHT = 512; +export const FLOATING_MARGIN = 16; +export const FLOATING_MIN_SIZE = { w: 320, h: 360 }; const RESIZE_EDGES: ResizeEdge[] = ["n", "e", "s", "w", "ne", "nw", "se", "sw"]; -function initialFloatingRect() { +export function initialFloatingRect() { if (typeof window === "undefined") { return { x: 0, y: 0, w: FLOATING_WIDTH, h: FLOATING_HEIGHT }; } @@ -59,12 +59,7 @@ export function agentHiddenContentClassName(fullscreen: boolean): string { return cn("h-full overflow-hidden", fullscreen && "invisible"); } -/** - * The floating chat window: `useDraggableResizable`-positioned bottom-right, draggable - * and resizable across the whole page. Fullscreen swaps it back to the same takeover the - * old right-column mode used, which needs `children` positioned inside a `relative` - * ancestor — the caller (`DashboardAgent`) supplies that. - */ +/** Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`; the caller (`DashboardAgent`) supplies it. */ export function FloatingAgentWindow({ fullscreen, children, @@ -86,9 +81,13 @@ export function FloatingAgentWindow({ return (
- {children(dragHandleProps)} + {/* Clips content to the rounded corners without clipping the resize handles below, + which sit half outside this box's edges. */} +
+ {children(dragHandleProps)} +
{RESIZE_EDGES.map((edge) => ( window.removeEventListener("resize", onResize); }, [viewportPadding]); - // Each onPan step folds `info.delta` (movement since the *last* event, not cumulative) - // onto the latest committed rect via the functional setState form. No gesture-start - // snapshot is kept: framer-motion defers onPanStart/onPanEnd by a frame but calls onPan - // synchronously, so a ref-based baseline captured in onPanStart can still be stale (or - // the mount-time initial rect) when the first onPan of a gesture lands. Delta + functional - // update has no baseline to go stale, so gestures compose correctly back-to-back. + // Each onPan step folds `info.delta` onto the latest committed rect via functional + // setState, with no gesture-start baseline kept: framer-motion can deliver onPan before + // onPanStart (its scheduler defers onPanStart by a frame), which would make a ref-based + // baseline stale. const dragHandleProps: PanHandlerProps = { onPanStart: () => {}, onPan: (_event, info: PanInfo) => { diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts index b44c6d6d4c2..c0f7ecf0490 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.test.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -195,14 +195,38 @@ describe("resizeRect", () => { }); }); -// These reproduce the live-testing symptoms: framer-motion defers onPanStart/onPanEnd by a -// frame (via its internal scheduler) but calls onPan synchronously, so a gesture-start -// snapshot captured in onPanStart can be stale — or still the mount-time initial rect — when -// a gesture's first onPan lands. applyDragDelta/applyResizeDelta take framer's per-event -// `delta` (not the cumulative `offset`) and fold it onto whatever rect is passed in, so the -// hook can drive them with `setRect(current => apply...(current, ...))` and never needs a -// separate baseline that could go stale. Simulating "gesture A steps, then gesture B steps, -// no reset in between" is exactly what a stale-baseline bug would fail on. +describe("resizeRect — minSize wins over a viewport-derived cap smaller than it", () => { + const minSize = { w: 320, h: 360 }; + + it("east: a narrow viewport still floors width at minSize.w", () => { + const start = { x: 100, y: 50, w: 300, h: 400 }; + const viewport = { width: 350, height: 800 }; + expect(resizeRect("e", start, 1000, 0, minSize, undefined, viewport, 16).w).toBe(320); + }); + + it("south: a short viewport still floors height at minSize.h", () => { + const start = { x: 50, y: 100, w: 400, h: 300 }; + const viewport = { width: 800, height: 400 }; + expect(resizeRect("s", start, 0, 1000, minSize, undefined, viewport, 16).h).toBe(360); + }); + + it("west: a small fixed right edge still floors width at minSize.w", () => { + const start = { x: 10, y: 50, w: 50, h: 400 }; + const viewport = { width: 1000, height: 800 }; + expect(resizeRect("w", start, -1000, 0, minSize, undefined, viewport, 16).w).toBe(320); + }); + + it("north: a small fixed bottom edge still floors height at minSize.h", () => { + const start = { x: 50, y: 10, w: 400, h: 50 }; + const viewport = { width: 1000, height: 800 }; + expect(resizeRect("n", start, 0, -1000, minSize, undefined, viewport, 16).h).toBe(360); + }); +}); + +// These reproduce the live-testing symptoms: framer-motion can deliver a gesture's first +// onPan before its onPanStart, so a baseline captured in onPanStart can be stale. +// applyDragDelta/applyResizeDelta avoid that by folding framer's per-event `delta` onto +// whatever rect is passed in, with no baseline to go stale. describe("applyResizeDelta / applyDragDelta — gesture sequencing", () => { const start: Rect = { x: 100, y: 100, w: 300, h: 200 }; const minSize = { w: 100, h: 80 }; @@ -248,10 +272,8 @@ describe("applyResizeDelta / applyDragDelta — gesture sequencing", () => { let rect = applyDragDelta(start, { x: 200, y: 150 }, viewport, padding); expect(rect).toEqual({ x: 300, y: 250, w: 300, h: 200 }); - // Resizing next must clamp against the *current* x/y (300, 250), not `start` (100, 100) - // or any other stale baseline — a stale baseline pinned near the right edge would show - // up here as the box jumping back toward x=690 (the viewport-clamped position for `start` - // near the right edge) instead of resizing in place. + // Resize must clamp against the current x/y (300, 250), not `start` — a stale baseline + // would show up as x jumping back toward 690, the viewport-clamped position near the edge. rect = applyResizeDelta("e", rect, { x: 10, y: 0 }, minSize, undefined, viewport, padding); expect(rect.x).toBe(300); expect(rect.w).toBe(310); diff --git a/apps/webapp/app/components/primitives/draggableResizableMath.ts b/apps/webapp/app/components/primitives/draggableResizableMath.ts index 42e8ad22f2e..5668b6e9803 100644 --- a/apps/webapp/app/components/primitives/draggableResizableMath.ts +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -60,21 +60,29 @@ export function resizeRect( ): Rect { let { x, y, w, h } = start; + // `minSize` wins over the viewport cap: a tiny viewport must not shrink the box + // below its minimum, so every per-edge cap is floored at the matching min dimension. if (edge.includes("e")) { - const maxW = Math.min(maxSize?.w ?? Infinity, viewport.width - padding - start.x); + const maxW = Math.max( + minSize.w, + Math.min(maxSize?.w ?? Infinity, viewport.width - padding - start.x) + ); w = clamp(start.w + dx, minSize.w, maxW); } if (edge.includes("s")) { - const maxH = Math.min(maxSize?.h ?? Infinity, viewport.height - padding - start.y); + const maxH = Math.max( + minSize.h, + Math.min(maxSize?.h ?? Infinity, viewport.height - padding - start.y) + ); h = clamp(start.h + dy, minSize.h, maxH); } if (edge.includes("w")) { - const maxW = Math.min(maxSize?.w ?? Infinity, start.x + start.w - padding); + const maxW = Math.max(minSize.w, Math.min(maxSize?.w ?? Infinity, start.x + start.w - padding)); w = clamp(start.w - dx, minSize.w, maxW); x = start.x + (start.w - w); } if (edge.includes("n")) { - const maxH = Math.min(maxSize?.h ?? Infinity, start.y + start.h - padding); + const maxH = Math.max(minSize.h, Math.min(maxSize?.h ?? Infinity, start.y + start.h - padding)); h = clamp(start.h - dy, minSize.h, maxH); y = start.y + (start.h - h); } @@ -83,17 +91,10 @@ export function resizeRect( } /** - * Applies one incremental pan step (framer-motion's `PanInfo.delta` — the movement since - * the *previous* event, not cumulative from gesture start) to `current` and re-clamps. - * - * Deliberately incremental rather than start-snapshot + cumulative-offset: framer-motion - * defers `onPanStart`/`onPanEnd` by a frame (via its internal scheduler) while `onPan` - * fires synchronously, so a start-rect ref captured in `onPanStart` can still hold a - * stale (or the mount-time initial) value when the gesture's first `onPan` lands — every - * later step then computes off the wrong baseline. Folding each step onto `current` - * (always the latest committed rect, via React's functional `setState`) has no baseline - * to go stale, so the race can't happen. Safe to call across gesture boundaries with no - * reset in between — each call is self-contained. + * Applies one incremental pan step (framer's per-event `delta`, not cumulative `offset`) + * to `current` and re-clamps. Incremental rather than start-snapshot-based because + * framer-motion can deliver a gesture's first `onPan` before its `onPanStart`, which + * would leave a snapshot baseline stale. */ export function applyDragDelta( current: Rect, diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index 9ca21a46ec9..bb99835f1b0 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -55,7 +55,6 @@ export type GalleryGroup = | "watches" | "watch-card" | "wakes" - | "shell" | "hero" | "prompts" | "intents" @@ -70,7 +69,6 @@ export type GallerySection = { }; export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: string }[] = [ - { group: "shell", page: "chat", label: "Floating window" }, { group: "hero", page: "chat", label: "Blank-state hero" }, { group: "prompts", page: "chat", label: "Suggested prompts" }, { group: "messages", page: "chat", label: "Message-level states" }, @@ -88,12 +86,6 @@ export const GALLERY_GROUPS: { group: GalleryGroup; page: GalleryPageId; label: ]; export const MANIFEST: GallerySection[] = [ - { - sectionId: "shell-floating-window", - title: "Draggable, resizable — the default and only chat mode", - group: "shell", - }, - { sectionId: "hero-panel", title: "Floating window content (380px) — no page context", diff --git a/apps/webapp/app/routes/storybook.agent-ui/route.tsx b/apps/webapp/app/routes/storybook.agent-ui/route.tsx index 3ac8a72871b..b1b2b732464 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/route.tsx +++ b/apps/webapp/app/routes/storybook.agent-ui/route.tsx @@ -1,96 +1,21 @@ import type { UIMessage } from "@ai-sdk/react"; import type { AgentPageContext, SuggestedPrompt } from "@internal/dashboard-agent-contracts"; -import { useLocation } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { demoFixtures, DemoIntentBubble } from "~/components/dashboard-agent/demo"; -import { - ChatProgress, - ChatText, - ChatTranscript, - ChatTurn, -} from "~/components/dashboard-agent/chat-layout"; +import { ChatProgress, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; import { DashboardAgentComposer } from "~/components/dashboard-agent/DashboardAgentComposer"; import { DashboardAgentContextBanner } from "~/components/dashboard-agent/DashboardAgentContextBanner"; -import { DashboardAgentHeader } from "~/components/dashboard-agent/DashboardAgentHeader"; -import type { DashboardAgentChat } from "~/components/dashboard-agent/DashboardAgentHistory"; import { DashboardAgentHero } from "~/components/dashboard-agent/DashboardAgentHero"; import { DashboardAgentMessages } from "~/components/dashboard-agent/DashboardAgentMessages"; import { DashboardAgentSuggestedPrompts } from "~/components/dashboard-agent/DashboardAgentSuggestedPrompts"; -import { AgentPanelColumn, FloatingAgentWindow } from "~/components/dashboard-agent/panel-layout"; +import { AgentPanelColumn } from "~/components/dashboard-agent/panel-layout"; import { liveProgress } from "~/components/dashboard-agent/progress-line"; import type { WakeWatch } from "~/components/dashboard-agent/WakeBanner"; import { WatchChips, type WatchChip } from "~/components/dashboard-agent/WatchChips"; -import { Button } from "~/components/primitives/Buttons"; import { cn } from "~/utils/cn"; import { demoTranscripts, investigationBlock, type DemoTranscript } from "./fixtures"; import { fixtureResolveUri, GalleryPage, noop, PANEL_FRAME } from "./gallery"; -const NO_CHATS: DashboardAgentChat[] = []; - -/** - * The dashboard agent's default (and only) mode: a floating window docked bottom-right, - * draggable across the whole page and resizable by its edges and corners. Toggled open - * here so the section demos the live shell, not a screenshot of it. - */ -function FloatingWindowHarness() { - const [open, setOpen] = useState(true); - const [fullscreen, setFullscreen] = useState(false); - - // Storybook only: the demo window must not follow you to another story. The real - // dashboard's chat intentionally persists across navigation — this effect is scoped to - // this story route and has no equivalent in DashboardAgent.tsx. - const { pathname } = useLocation(); - const previousPathname = useRef(pathname); - useEffect(() => { - if (previousPathname.current === pathname) return; - previousPathname.current = pathname; - setOpen(false); - }, [pathname]); - - return ( - <> -
- -
- {open && ( - - {(dragHandleProps) => ( -
- - setFullscreen((current) => !current)} - isFullscreen={fullscreen} - onClose={() => setOpen(false)} - /> - - - - - - - - - -
- )} -
- )} - - ); -} - const { demoIntents, demoWatches, demoPageContexts, demoInvestigations } = demoFixtures; function MessageHarness({ @@ -317,8 +242,6 @@ function WakeHarness({ message, watches }: { message: UIMessage; watches?: WakeW } const STATES: Record = { - "shell-floating-window": , - "hero-panel": , "hero-panel-contextual": , "hero-fullscreen": , @@ -415,7 +338,6 @@ export default function Story() { page="chat" states={STATES} componentNames={[ - "panel-layout.tsx", "DashboardAgentComposer.tsx", "DashboardAgentMessages.tsx", "DashboardAgentHero.tsx", diff --git a/apps/webapp/app/routes/storybook.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index 1eb17a8a949..6278f990a3d 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -193,20 +193,14 @@ function DotMatrixTab() { // 1.5x the 32px candidate icons this replaced. const EDITOR_SIZE = 48; -/** - * Renders a flat `MATRIX * MATRIX` lit/unlit array on the shared grid geometry — - * `dotMatrixGeometry`, so pitch and dot radius always match the Shape library above. - * Shared by the interactive editor (`onToggle`) and the static owner-bitmap previews. - */ +/** Renders a flat `MATRIX * MATRIX` lit/unlit array using `dotMatrixGeometry`, so pitch and dot radius always match the Shape library above. */ function DotGrid({ lit, size, - litClassName = "text-success", onToggle, }: { lit: boolean[]; size: number; - litClassName?: string; onToggle?: (index: number) => void; }) { const { pitch, dotR } = dotMatrixGeometry(size); @@ -230,8 +224,22 @@ function DotGrid({ r={dotR} fill="currentColor" opacity={isLit ? 1 : 0.25} - className={cn(onToggle && "cursor-pointer", isLit ? litClassName : "text-text-dimmed")} + className={cn( + onToggle && "cursor-pointer focus-visible:outline focus-visible:outline-2", + isLit ? "text-success" : "text-text-dimmed" + )} onClick={onToggle ? () => onToggle(i) : undefined} + {...(onToggle && { + role: "button", + tabIndex: 0, + "aria-pressed": isLit, + "aria-label": `Dot ${r + 1}, ${c + 1}`, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onToggle(i); + }, + })} /> ); })} diff --git a/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx index ca67297e23a..54205959c3d 100644 --- a/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx +++ b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx @@ -12,15 +12,22 @@ import { Paragraph } from "~/components/primitives/Paragraph"; const NO_CHATS: DashboardAgentChat[] = []; -/** - * The dashboard agent's default (and only) mode: a floating window docked at the - * bottom of the page, draggable across the whole page and resizable by its edges and - * corners. Static content only — this demos the shell (`FloatingAgentWindow`, the real - * `DashboardAgentHeader`, and `agentTakeoverClassName` fullscreen), not a live backend. - */ +/** Static content only: this demos the shell (drag, resize, fullscreen), not a live backend. */ export default function Story() { const [open, setOpen] = useState(true); const [fullscreen, setFullscreen] = useState(false); + const closeWindow = () => { + setOpen(false); + setFullscreen(false); + }; + + // SSR has no window, so the initial rect (and hydrated one) would mismatch; render the + // demo only once mounted client-side. + const [mounted, setMounted] = useState(false); + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- SSR has no window; flips once client-mounted. + setMounted(true); + }, []); // Storybook only: the demo window must not follow you to another story. The real // dashboard's chat intentionally persists across navigation — this effect is scoped to @@ -30,7 +37,7 @@ export default function Story() { useEffect(() => { if (previousPathname.current === pathname) return; previousPathname.current = pathname; - setOpen(false); + closeWindow(); }, [pathname]); return ( @@ -47,14 +54,14 @@ export default function Story() { the page the same way the old side panel did.
- {!open && ( + {mounted && !open && (
)} - {open && ( + {mounted && open && ( {(dragHandleProps) => (
@@ -71,7 +78,7 @@ export default function Story() { onDeleteChat={() => {}} onToggleFullscreen={() => setFullscreen((f) => !f)} isFullscreen={fullscreen} - onClose={() => setOpen(false)} + onClose={closeWindow} /> From 8ce45c3aa265fb2c13c8b38244b97fec8be3ae46 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Wed, 26 Aug 2026 19:06:58 +0000 Subject: [PATCH 22/43] fix(webapp): drag filter opts out by marker, not tag, and lives in the shell closest("button, a, input, [role=button]") rejected pans starting on the header title, since the whole title (including the truncated text) sits inside a Popover trigger
+ ); +} + export function DashboardAgentHeader({ title, chats, @@ -62,9 +131,6 @@ export function DashboardAgentHeader({ const [isHistoryOpen, setHistoryOpen] = useState(false); const [historyOpenedAt, setHistoryOpenedAt] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); - const [isModeMenuOpen, setModeMenuOpen] = useState(false); - const CurrentModeIcon = - MODE_OPTIONS.find((option) => option.mode === mode)?.Icon ?? ChatFloatingPanel; return (
@@ -118,31 +184,7 @@ export function DashboardAgentHeader({ />
- - -
); } From fe35c952ca2b18173fc1d319aaba58ecf95c9521 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Fri, 28 Aug 2026 15:39:28 +0000 Subject: [PATCH 42/43] fix(webapp): keep draft chat composer in the hero block --- .../dashboard-agent/DashboardAgentChat.tsx | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index 026ed700ebf..6496a870e7d 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -449,18 +449,48 @@ export function DashboardAgentChat({ onActivityChange?.(chatId, activity); }, [chatId, activity, onActivityChange]); + const isDraftState = messages.length === 0 && !pendingFirstMessage; + + const contextBanner = ( + + ); + return ( <> watch.status === "active")} onCancel={onCancelWatch} /> - {messages.length === 0 && !pendingFirstMessage ? ( + {isDraftState ? ( + ) : ( + submit(input)} + onStop={stop} + isStreaming={isStreaming} + focusKey={sendRequest?.seq} + context={contextBanner} + /> + ) + } /> ) : ( )} {watchCard ?
{watchCard}
: null} - {atMessageCap ? ( + {isDraftState ? null : atMessageCap ? ( - } + context={contextBanner} /> ) : ( <> @@ -498,13 +522,7 @@ export function DashboardAgentChat({ onStop={stop} isStreaming={isStreaming} focusKey={sendRequest?.seq} - context={ - - } + context={contextBanner} trailingAction={ showNewChat && (