diff --git a/.server-changes/floating-chat-window.md b/.server-changes/floating-chat-window.md new file mode 100644 index 00000000000..caef07f812e --- /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. You can also switch to a right-side panel or fullscreen view using a toggle in the chat header. diff --git a/apps/webapp/app/assets/icons/ChatFloatingPanel.tsx b/apps/webapp/app/assets/icons/ChatFloatingPanel.tsx new file mode 100644 index 00000000000..ccb283128c5 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChatFloatingPanel.tsx @@ -0,0 +1,23 @@ +export function ChatFloatingPanel({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChatFullScreen.tsx b/apps/webapp/app/assets/icons/ChatFullScreen.tsx new file mode 100644 index 00000000000..39a2df5be32 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChatFullScreen.tsx @@ -0,0 +1,20 @@ +export function ChatFullScreen({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChatRightPanel.tsx b/apps/webapp/app/assets/icons/ChatRightPanel.tsx new file mode 100644 index 00000000000..e7056149906 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChatRightPanel.tsx @@ -0,0 +1,20 @@ +export function ChatRightPanel({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx index 3d356d9b548..30060dede0b 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx @@ -2,6 +2,7 @@ import type { SuggestedPrompt, WatchSpec } from "@internal/dashboard-agent-contr import { useLocation } from "@remix-run/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + collapsibleHandleClassName, ResizableHandle, ResizablePanel, ResizablePanelGroup, @@ -17,9 +18,10 @@ import { DashboardAgentProvider, TOGGLE_PANEL_SHORTCUT } from "./dashboardAgentL import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest"; import { agentHiddenContentClassName, - agentTakeoverClassName, - readAgentFullscreen, - writeAgentFullscreen, + FloatingAgentWindow, + readAgentMode, + writeAgentMode, + type DashboardAgentMode, } from "./panel-layout"; import { nextPendingTurnChatId } from "./pending-turn"; import { nextVisibleChat } from "./unread-counts"; @@ -114,14 +116,13 @@ export function DashboardAgent({ setUnreadWakes(initialUnreadWakes); setUnreadWork(initialUnreadWork); }, [environment.id, initialUnreadWakes, initialUnreadWork]); - // Read lazily so SSR always renders the side panel. - const [fullscreen, setFullscreen] = useState(readAgentFullscreen); + // Read lazily: SSR has no localStorage, so the server always renders the floating default. + const [mode, setMode] = useState(readAgentMode); + const fullscreen = mode === "fullscreen"; - const toggleFullscreen = useCallback(() => { - setFullscreen((current) => { - writeAgentFullscreen(!current); - return !current; - }); + const changeMode = useCallback((next: DashboardAgentMode) => { + writeAgentMode(next); + setMode(next); }, []); // Pathname only: filter and search-param changes must keep fullscreen. @@ -130,9 +131,10 @@ export function DashboardAgent({ useEffect(() => { if (previousPathname.current === pathname) return; previousPathname.current = pathname; - setFullscreen((current) => { - if (current) writeAgentFullscreen(false); - return false; + setMode((current) => { + if (current !== "fullscreen") return current; + writeAgentMode("floating"); + return "floating"; }); }, [pathname]); const [newChatSeq, setNewChatSeq] = useState(0); @@ -165,8 +167,11 @@ export function DashboardAgent({ setOpen(false); // Pending requests must be dropped or a stale one re-applies on the next open. visibleChat.current = null; - setFullscreen(false); - writeAgentFullscreen(false); + setMode((current) => { + if (current !== "fullscreen") return current; + writeAgentMode("floating"); + return "floating"; + }); setRequestedMessage(undefined); setOpenChatRequest(undefined); setWatchRequest(undefined); @@ -346,7 +351,10 @@ export function DashboardAgent({ return ( {open ? ( - // `relative` is the takeover's containing block. + // `relative` is the fullscreen takeover's containing block. The ResizablePanelGroup + // stays mounted across all three modes — only its sizing/handle degenerate outside + // rightPanel — so `FloatingAgentWindow` (and the chat panel inside it) sits at the + // same tree position in every mode and a mode switch never remounts it. - - - 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, dragHandleClassName }) => ( + 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} + mode={mode} + onModeChange={changeMode} + dragHandleProps={dragHandleProps} + dragHandleClassName={dragHandleClassName} + /> + )} + diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx index c36a9692e71..6496a870e7d 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx @@ -10,11 +10,15 @@ import { import { useLocation, useNavigate } from "@remix-run/react"; import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; import { useCallback, useEffect, useRef, useState } from "react"; +import { PlusIcon } from "~/assets/icons/PlusIcon"; +import { Button } from "~/components/primitives/Buttons"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { useToast } from "~/components/primitives/Toast"; import { AgentQuotaNotice, AgentUpgradeBlock } from "./AgentUpgradeGate"; import { DashboardAgentComposer } from "./DashboardAgentComposer"; import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner"; import { DashboardAgentHero } from "./DashboardAgentHero"; +import { NEW_CHAT_SHORTCUT } from "./DashboardAgentHeader"; import { DashboardAgentMessages, type TurnActivity } from "./DashboardAgentMessages"; import { MESSAGE_TOO_LARGE_ERROR } from "./message-limits"; import { @@ -46,6 +50,23 @@ export type DashboardAgentSession = { lastEventId?: string; }; +/** The transport's `sessions` option for one chat. Extracted so the resume wiring is testable. */ +export function chatSessionsOption( + chatId: string, + session: DashboardAgentSession | null, + streaming: boolean | undefined +) { + if (!session) return undefined; + return { + [chatId]: { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + // Mid-turn chats must be marked streaming or the transport won't resume `session.out`. + isStreaming: streaming ?? false, + }, + }; +} + // Matches the agent's clientDataSchema input. export type DashboardAgentClientData = { userId: string; @@ -80,6 +101,8 @@ export function DashboardAgentChat({ onTurnSettled, onActivityChange, onQuotaChange, + onNewChat, + showNewChat, }: { chatId: string; initialMessages: UIMessage[]; @@ -109,6 +132,8 @@ export function DashboardAgentChat({ onActivityChange?: (chatId: string, activity: TurnActivity | null) => void; /** The poll lives here, so this is where the panel learns the cap has lifted. */ onQuotaChange?: (quota: MessageQuota) => void; + onNewChat: () => void; + showNewChat: boolean; }) { const [input, setInput] = useState(""); // Set when the server refuses a send over the cap, so the block shows at once rather than @@ -158,16 +183,7 @@ export function DashboardAgentChat({ return res; }, clientData, - sessions: session - ? { - [chatId]: { - publicAccessToken: session.publicAccessToken, - lastEventId: session.lastEventId, - // Mid-turn chats must be marked streaming or the transport won't resume `session.out`. - isStreaming: streaming ?? false, - }, - } - : undefined, + sessions: chatSessionsOption(chatId, session, streaming), startSession: async ({ chatId }) => { const body = new FormData(); body.set("intent", "start"); @@ -433,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} /> ) : ( <> @@ -482,12 +522,23 @@ export function DashboardAgentChat({ onStop={stop} isStreaming={isStreaming} focusKey={sendRequest?.seq} - context={ - + context={contextBanner} + trailingAction={ + showNewChat && ( + + New chat + + + } + onClick={onNewChat} + LeadingIcon={} + /> + ) } /> {quota.kind === "within" && ( diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index 3ecfba0fe9d..147f599326b 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -20,6 +20,7 @@ export function DashboardAgentComposer({ isStreaming, focusKey, context, + trailingAction, layout = "docked", autoFocus = true, placeholderSuggestion, @@ -32,6 +33,8 @@ export function DashboardAgentComposer({ // Bump to move focus back to the textarea. focusKey?: string | number; context?: React.ReactNode; + // Rendered right-aligned next to `context`, below the input. + trailingAction?: React.ReactNode; layout?: DashboardAgentComposerLayout; autoFocus?: boolean; // Shown as the placeholder while the field is empty. Tab accepts it as editable @@ -81,7 +84,6 @@ export function DashboardAgentComposer({ isHero ? "w-full" : "bg-background-bright px-3 pb-3 pt-1" )} > - {isHero ? null : context} + {isHero ? null : ( + + {context ?? } + {trailingAction} + + )} {/* Mounted from the start, empty until there is something to say: a region that appears with its first message goes unannounced in several screen readers. */} diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts new file mode 100644 index 00000000000..5c4beb07fbb --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts @@ -0,0 +1,91 @@ +// @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, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { ModeToggle } from "./DashboardAgentHeader"; + +function withProviders(children: React.ReactNode) { + return createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement(ShortcutsProvider, null, children) + ); +} + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + } + container?.remove(); + container = undefined; + root = undefined; +}); + +function renderToggle(mode: "floating" | "rightPanel" | "fullscreen", onModeChange: () => void) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode, onModeChange }))); + }); + return container; +} + +// Row-reverse layout keeps the trigger as the first button in DOM order. +function getTrigger(el: HTMLElement) { + return el.querySelectorAll("button")[0] as HTMLButtonElement; +} + +function expandToggle(el: HTMLElement) { + const trigger = getTrigger(el); + act(() => { + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("ModeToggle", () => { + it("collapses on Escape without changing mode, and marks the event handled", () => { + const onModeChange = vi.fn(); + const el = renderToggle("floating", onModeChange); + expandToggle(el); + expect(getTrigger(el).getAttribute("aria-expanded")).toBe("true"); + + // cancelable: true, like a real native keydown; otherwise preventDefault() is a no-op. + const escapeEvent = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + act(() => { + document.dispatchEvent(escapeEvent); + }); + + expect(escapeEvent.defaultPrevented).toBe(true); + expect(getTrigger(el).getAttribute("aria-expanded")).toBe("false"); + expect(onModeChange).not.toHaveBeenCalled(); + }); + + it("collapses when mode changes externally", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const onModeChange = vi.fn(); + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode: "floating", onModeChange }))); + }); + expandToggle(container); + expect(getTrigger(container).getAttribute("aria-expanded")).toBe("true"); + + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode: "fullscreen", onModeChange }))); + }); + + expect(getTrigger(container).getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx index 1b6e5bb9db3..54bab3535eb 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx @@ -1,7 +1,9 @@ -import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; -import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { ChatFloatingPanel } from "~/assets/icons/ChatFloatingPanel"; +import { ChatFullScreen } from "~/assets/icons/ChatFullScreen"; +import { ChatRightPanel } from "~/assets/icons/ChatRightPanel"; import { CrossIcon } from "~/assets/icons/CrossIcon"; -import { PlusIcon } from "~/assets/icons/PlusIcon"; import { Button } from "~/components/primitives/Buttons"; import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; @@ -12,6 +14,14 @@ import { type DashboardAgentChat, } from "./DashboardAgentHistory"; import { chatHistoryTriggerLabel } from "./header-labels"; +import type { DashboardAgentMode } from "./panel-layout"; + +const MODE_OPTIONS: { mode: DashboardAgentMode; label: string; Icon: typeof ChatFloatingPanel }[] = + [ + { mode: "floating", label: "Floating", Icon: ChatFloatingPanel }, + { mode: "rightPanel", label: "Right panel", Icon: ChatRightPanel }, + { mode: "fullscreen", label: "Fullscreen", Icon: ChatFullScreen }, + ]; // Display only. The key is registered once, in `DashboardAgent`; registering it // anywhere else makes the keystroke fire twice. @@ -21,31 +31,119 @@ export const NEW_CHAT_SHORTCUT: Shortcut = { enabledOnInputElements: true, }; +export function ModeToggle({ + mode, + onModeChange, +}: { + mode: DashboardAgentMode; + onModeChange: (mode: DashboardAgentMode) => void; +}) { + const [isExpanded, setExpanded] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const currentOption = MODE_OPTIONS.find((option) => option.mode === mode) ?? MODE_OPTIONS[0]; + const otherOptions = MODE_OPTIONS.filter((option) => option.mode !== mode); + + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- collapses when `mode` changes externally (e.g. route-driven). + setExpanded(false); + }, [mode]); + + // Button doesn't forward arbitrary aria props, so set them directly on the node. + useEffect(() => { + const el = triggerRef.current; + if (!el) return; + el.setAttribute("aria-haspopup", "true"); + el.setAttribute("aria-expanded", String(isExpanded)); + }, [isExpanded]); + + useEffect(() => { + if (!isExpanded) return; + + function handlePointerDown(event: PointerEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setExpanded(false); + } + } + + // Capture phase + preventDefault: DashboardAgentPanel's own Escape handler + // (bubble phase) checks defaultPrevented before closing the whole panel. + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setExpanded(false); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isExpanded]); + + return ( + + setExpanded((open) => !open)} + LeadingIcon={} + /> + + {isExpanded && + otherOptions.map(({ mode: option, label, Icon }) => ( + + { + onModeChange(option); + setExpanded(false); + }} + LeadingIcon={} + /> + + ))} + + + ); +} + export function DashboardAgentHeader({ title, chats, currentChatId, thinkingChatId, - onNewChat, - showNewChat, onOpenHistory, onSelectChat, onDeleteChat, - onToggleFullscreen, - isFullscreen, + mode, + onModeChange, onClose, }: { title: string; chats: DashboardAgentChat[]; currentChatId: string; thinkingChatId?: string | null; - onNewChat: () => void; - showNewChat: boolean; onOpenHistory: () => void; onSelectChat: (chatId: string) => void; onDeleteChat: (chatId: string) => void; - onToggleFullscreen: () => void; - isFullscreen: boolean; + mode: DashboardAgentMode; + onModeChange: (mode: DashboardAgentMode) => void; onClose: () => void; }) { const [isHistoryOpen, setHistoryOpen] = useState(false); @@ -103,36 +201,8 @@ export function DashboardAgentHeader({ onConfirm={onDeleteChat} /> - - {showNewChat && ( - - New chat - - - } - onClick={onNewChat} - LeadingIcon={} - /> - )} - - ) : ( - - ) - } - /> + + void; - isFullscreen?: boolean; - onToggleFullscreen?: () => void; + mode?: DashboardAgentMode; + onModeChange?: (mode: DashboardAgentMode) => void; + /** Spread onto the header, which is the floating window's drag handle; already filtered by `FloatingAgentWindow`. */ + dragHandleProps?: Partial; + dragHandleClassName?: string; // Every `seq` below distinguishes repeat requests with identical contents. requestedMessage?: { text: string; seq: number }; openChatRequest?: { chatId: string; seq: number }; @@ -127,7 +134,6 @@ export function DashboardAgentPanel({ const [loading, setLoading] = useState( () => readLastChat(storageKey)?.path === location.pathname ); - const currentPage = agentPageLabel(pageContext, location.pathname); const pagePaths = useMemo>( @@ -589,7 +595,7 @@ export function DashboardAgentPanel({ return ( { if ( @@ -604,23 +610,23 @@ export function DashboardAgentPanel({ onClose(); }} > - {})} - isFullscreen={isFullscreen} - onClose={onClose} - /> + + {})} + onClose={onClose} + /> + {/* Always mounted, so the chat keeps its transport, session and transcript. */} - + {loading ? ( @@ -655,6 +661,8 @@ export function DashboardAgentPanel({ onTurnSettled={loadHistory} onActivityChange={handleActivityChange} onQuotaChange={handleQuotaChange} + onNewChat={newChat} + showNewChat={active !== null} /> ) : ( } fullWidth={fullWidth} textAlignLeft={fullWidth} className={className} diff --git a/apps/webapp/app/components/dashboard-agent/WatchButton.tsx b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx index 25b000cd656..100d9ecb975 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchButton.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx @@ -1,5 +1,5 @@ -import { EyeIcon } from "@heroicons/react/20/solid"; import type { WatchSpec } from "@internal/dashboard-agent-contracts"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { useDashboardAgent } from "./dashboardAgentLauncher"; import { watchTooltipLabel } from "~/presenters/v3/dashboardAgent"; @@ -31,8 +31,7 @@ export function WatchButton({ } fullWidth={fullWidth} textAlignLeft={fullWidth} className={className} diff --git a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx index 551a9683ef5..6b8f9abe907 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx @@ -10,31 +10,31 @@ * append time by `app/presenters/v3/dashboardAgent`, so a later copy change never rewrites what * a user was already told. */ -import { CheckCircleIcon, EyeIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; +import { CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import type { WatchResultBlock as WatchResultBlockPayload } from "@internal/dashboard-agent-contracts"; +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 eye; the check belongs to the one-shot that did - * answer the question. - */ +/** `watching` is a live watch, so it gets the chat's spinner; the terminal outcomes keep static icons. */ const OUTCOME = { - watching: { label: "Watch", Icon: EyeIcon, tone: "neutral" }, - already_true: { label: "Watch", Icon: CheckCircleIcon, tone: "success" }, - impossible: { label: "Watch", Icon: InformationCircleIcon, tone: "neutral" }, + watching: { label: "Watch", icon: }, + already_true: { + label: "Watch", + icon: , + }, + impossible: { + label: "Watch", + icon: , + }, } as const; export function WatchResultBlock({ block }: { block: WatchResultBlockPayload }) { - const { label, Icon, tone } = OUTCOME[block.outcome] ?? OUTCOME.watching; + const { label, icon } = OUTCOME[block.outcome] ?? OUTCOME.watching; return ( - } - > + {block.headline} {block.lifetime ? {block.lifetime} : null} {block.detail ? {block.detail} : null} diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index 052824aa25c..e623565fb15 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -46,29 +46,31 @@ 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, 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 ? ( + "Close chat" + ) : ( + + Open chat + + + ) } button={ setOpen(true)} + aria-label={open ? "Close chat" : hasUnread ? `${ASK_AGENT_LABEL}, unread updates` : ASK_AGENT_LABEL} + onClick={() => setOpen(!open)} > {ASK_AGENT_LABEL} diff --git a/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts b/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts new file mode 100644 index 00000000000..e1ec956ac9c --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts @@ -0,0 +1,41 @@ +// Guards the tree-shape invariant: DashboardAgent.tsx must mount FloatingAgentWindow +// exactly once, never branch it behind a mode check, and gate the right-column sizing +// on `mode === "rightPanel"` rather than swapping in a whole separate element tree. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const DIR = __dirname; + +function read(file: string): string { + return readFileSync(join(DIR, file), "utf8"); +} + +describe("DashboardAgent.tsx keeps one tree shape across display modes", () => { + const source = read("DashboardAgent.tsx"); + + it("mounts FloatingAgentWindow exactly once, unconditionally", () => { + const occurrences = source.match(/ { + expect(source).toContain("FloatingAgentWindow"); + }); + + it('gates the right-column sizing on mode === "rightPanel", not a branch around FloatingAgentWindow', () => { + expect(source).toContain('collapsed={mode !== "rightPanel"}'); + }); + + it("keeps ResizableHandle always mounted — a conditional handle shifts sibling keys and remounts the chat", () => { + const occurrences = source.match(/ { + expect(source).toContain('"overflow-visible!"'); + expect(source).not.toContain('"!overflow-visible"'); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts b/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts index 0f38b54f752..6b05b925d1d 100644 --- a/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts +++ b/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts @@ -10,18 +10,37 @@ const message: UIMessage = { parts: [{ type: "text", text: "why did this run fail?" }], }; +// A tool call the stream died on: still `input-available`, no result part. +const unfinishedMessage: UIMessage = { + id: "msg_2", + role: "assistant", + parts: [{ type: "tool-run_query", state: "input-available" } as never], +}; + describe("resolveOpenedChat", () => { it("opens a chat that has messages", () => { const opened = resolveOpenedChat(CHAT_ID, { messages: [message], session: null }); - expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [message], session: null }); + expect(opened).toEqual({ + kind: "chat", + chatId: CHAT_ID, + messages: [message], + session: null, + streaming: false, + }); }); it("still opens a chat that exists but has no messages", () => { const opened = resolveOpenedChat(CHAT_ID, { messages: [], session: null }); expect(opened.kind).toBe("chat"); - expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [], session: null }); + expect(opened).toEqual({ + kind: "chat", + chatId: CHAT_ID, + messages: [], + session: null, + streaming: false, + }); }); it("treats a chat with no messages field the same way", () => { @@ -54,4 +73,23 @@ describe("resolveOpenedChat", () => { it("has no session when the token is missing", () => { expect(resolveOpenedChat(CHAT_ID, { messages: [message] })).toMatchObject({ session: null }); }); + + // The bug this guards: closing mid-turn and reopening must resume, not show a stalled turn. + it("marks streaming when the fetched transcript still looks mid-turn", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [message, unfinishedMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + + expect(opened).toMatchObject({ streaming: true }); + }); + + it("is not streaming once the transcript settles", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [message], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + + expect(opened).toMatchObject({ streaming: false }); + }); }); diff --git a/apps/webapp/app/components/dashboard-agent/opened-chat.ts b/apps/webapp/app/components/dashboard-agent/opened-chat.ts index f9dd64f89e9..404145eb718 100644 --- a/apps/webapp/app/components/dashboard-agent/opened-chat.ts +++ b/apps/webapp/app/components/dashboard-agent/opened-chat.ts @@ -1,4 +1,5 @@ import type { UIMessage } from "@ai-sdk/react"; +import { transcriptLooksUnfinished } from "./settled-transcript"; export type OpenedChatResponse = { messages?: UIMessage[]; @@ -11,6 +12,8 @@ export type OpenedChat = chatId: string; messages: UIMessage[]; session: { publicAccessToken: string; lastEventId?: string } | null; + // True if the transcript still reads as mid-turn, so the transport resumes it. + streaming: boolean; } // Deleted, or belonging to someone else: the read failed, so there is no chat to show. | { kind: "gone" }; @@ -23,15 +26,17 @@ export function resolveOpenedChat( if (!response) return { kind: "gone" }; const session = response.session; + const messages = response.messages ?? []; return { kind: "chat", chatId, - messages: response.messages ?? [], + messages, session: session?.publicAccessToken ? { publicAccessToken: session.publicAccessToken, lastEventId: session.lastEventId ?? undefined, } : null, + streaming: transcriptLooksUnfinished(messages), }; } 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..58c5c1c15c2 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +import { Panel, PanelGroup, PanelResizer } from "@window-splitter/react"; +import { createElement, useEffect, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PanInfo } from "framer-motion"; +import { useDraggableResizable } from "~/components/primitives/DraggableResizable"; +import { + FLOATING_HEIGHT, + FLOATING_MARGIN, + FLOATING_MIN_SIZE, + FLOATING_WIDTH, + FloatingAgentWindow, + initialFloatingRect, + type DashboardAgentMode, + type FloatingDragProps, +} from "./panel-layout"; + +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; + +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, + }); + }); +}); + +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); + }); +}); + +// Mirrors the real header: a title-like element (draggable) beside a +// `data-agent-no-drag` action (opted out), same as DashboardAgentHeader's button group. +function renderFloatingAgentWindow() { + let latest!: FloatingDragProps; + function Harness() { + return createElement(FloatingAgentWindow, { mode: "floating" }, (drag: FloatingDragProps) => { + // oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value. + latest = drag; + return createElement( + "div", + null, + createElement("span", { "data-testid": "title" }, "Chat title"), + createElement("button", { "data-agent-no-drag": "", "data-testid": "action" }, "Close") + ); + }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness)); + }); + return { + get dragHandleProps() { + return latest.dragHandleProps; + }, + outerLeft: () => (container!.firstElementChild as HTMLDivElement).style.left, + titleEl: () => container!.querySelector('[data-testid="title"]')!, + actionEl: () => container!.querySelector('[data-testid="action"]')!, + }; +} + +describe("FloatingAgentWindow's drag-vs-click filter", () => { + it("drags when a gesture starts on ordinary content, like the header title", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.titleEl() as unknown as PointerEvent["target"]; + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).not.toBe(startLeft); + }); + + it("does not drag when a gesture starts on a data-agent-no-drag element", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.actionEl() as unknown as PointerEvent["target"]; + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).toBe(startLeft); + }); + + it("does not leak a delta when onPan for a no-drag target lands before its onPanStart", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.actionEl() as unknown as PointerEvent["target"]; + // Framer-motion's real ordering: onPan can arrive first. + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).toBe(startLeft); + }); +}); + +describe("FloatingAgentWindow keeps its child mounted across every mode transition", () => { + it("never remounts the child across any of the three modes (same tree shape always)", () => { + let mounts = 0; + function Marker() { + useEffect(() => { + mounts += 1; + }, []); + return null; + } + function Harness({ mode }: { mode: DashboardAgentMode }) { + return createElement(FloatingAgentWindow, { mode }, () => createElement(Marker)); + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + // Every pairwise transition among the three modes, in both directions. + const sequence: DashboardAgentMode[] = [ + "floating", + "rightPanel", + "floating", + "fullscreen", + "rightPanel", + "fullscreen", + "floating", + ]; + for (const mode of sequence) { + act(() => { + root!.render(createElement(Harness, { mode })); + }); + expect(mounts).toBe(1); + } + }); +}); + +describe("FloatingAgentWindow's fullscreen geometry", () => { + it("pins the exact takeover classes, including the flex column that fills the takeover's height", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(FloatingAgentWindow, { mode: "fullscreen" }, () => null)); + }); + const outer = container.firstElementChild as HTMLDivElement; + expect(outer.className).toBe("absolute inset-0 z-10 flex flex-col bg-background-bright"); + expect(outer.classList.contains("flex")).toBe(true); + expect(outer.classList.contains("flex-col")).toBe(true); + expect(outer.getAttribute("style")).toBeNull(); + }); +}); + +// Mirrors DashboardAgent.tsx's grid: a content panel, a handle only in rightPanel mode, +// and an agent panel that's either sized (rightPanel) or truly collapsed (otherwise). +// A leftover fixed-pixel track from a hidden-not-unmounted handle, or from a "0px" panel +// that doesn't actually collapse, pushes the grid past its own container's width. +// The handle is ALWAYS mounted (only its `size` varies) — PanelGroup keys children by +// index after dropping falsy ones (@window-splitter/react's useIndexedChildren), so a +// conditionally-rendered handle shifts the agent panel's key on every mode switch and +// remounts the whole chat subtree beneath it. +function renderDashboardAgentGrid(rightPanel: boolean) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( + createElement( + PanelGroup, + { orientation: "horizontal" }, + createElement(Panel, { id: "dashboard-content", min: "320px" }), + createElement(PanelResizer, { + id: "dashboard-agent-handle", + size: rightPanel ? "3px" : "0px", + }), + createElement(Panel, { + id: "dashboard-agent-panel", + default: "380px", + min: "320px", + max: "720px", + collapsible: true, + collapsed: !rightPanel, + collapsedSize: "0px", + }) + ) + ); + }); + return container.firstElementChild as HTMLElement; +} + +describe("FloatingAgentWindow clears floating geometry when it docks", () => { + it("leaves no stale position/left/top/width/height after a drag, then switching to rightPanel", () => { + stubViewport(1200, 900); + let latest!: FloatingDragProps; + function Harness({ mode }: { mode: DashboardAgentMode }) { + return createElement(FloatingAgentWindow, { mode }, (drag: FloatingDragProps) => { + // oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value. + latest = drag; + return null; + }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness, { mode: "floating" })); + }); + act(() => { + latest.dragHandleProps.onPanStart!(fakeEvent, fakePanInfo(0, 0)); + latest.dragHandleProps.onPan!(fakeEvent, fakePanInfo(-40, -10)); + }); + + act(() => { + root!.render(createElement(Harness, { mode: "rightPanel" })); + }); + + const node = container.firstElementChild as HTMLDivElement; + expect(node.style.position).toBe(""); + expect(node.style.left).toBe(""); + expect(node.style.top).toBe(""); + expect(node.style.width).toBe(""); + expect(node.style.height).toBe(""); + }); + + // Mirrors FloatingAgentWindow's own style ternary directly against the resize path + // (which changes width/height, not just position — resizeHandleProps isn't exposed + // through the render prop, so this drives the same underlying hook instead). + it("leaves no stale geometry after a resize (width/height change), then docking", () => { + stubViewport(1200, 900); + let latest!: ReturnType; + // Matches FloatingAgentWindow's own fix: an explicit reset object, not `undefined` — + // a dropped style key isn't guaranteed to clear on every style-application layer. + const clearedStyle = { + position: undefined, + left: undefined, + top: undefined, + width: undefined, + height: undefined, + }; + function Mirror({ docked }: { docked: boolean }) { + // 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 createElement("div", { style: docked ? clearedStyle : latest.style }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Mirror, { docked: false })); + }); + act(() => latest.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(60, 0))); + + act(() => { + root!.render(createElement(Mirror, { docked: true })); + }); + + const node = container.firstElementChild as HTMLDivElement; + expect(node.style.position).toBe(""); + expect(node.style.width).toBe(""); + expect(node.style.height).toBe(""); + }); +}); + +describe("DashboardAgent's degenerate grid tracks outside rightPanel", () => { + it("keeps the handle mounted but collapses it and the agent panel to bare 0px tracks", () => { + const group = renderDashboardAgentGrid(false); + expect(group.querySelector('[data-splitter-type="handle"]')).not.toBeNull(); + const columns = group.style.gridTemplateColumns; + expect(columns.endsWith("0px")).toBe(true); + expect(columns).not.toMatch(/\b3px\b/); + }); + + it("keeps the handle and a real sized track in rightPanel mode", () => { + const group = renderDashboardAgentGrid(true); + expect(group.querySelector('[data-splitter-type="handle"]')).not.toBeNull(); + expect(group.style.gridTemplateColumns).toMatch(/\b3px\b/); + }); +}); + +// Mirrors DashboardAgent.tsx's real PanelGroup/Panel/PanelResizer shape (not the +// FloatingAgentWindow-standalone test above, which never puts a handle between the +// panels and so is blind to the sibling-key-shift bug this pins). +function renderDashboardAgentGridTree(rightPanel: boolean, agentChild: ReactNode) { + return createElement( + PanelGroup, + { orientation: "horizontal" }, + createElement(Panel, { id: "dashboard-content", min: "320px" }), + createElement(PanelResizer, { + id: "dashboard-agent-handle", + size: rightPanel ? "3px" : "0px", + }), + createElement( + Panel, + { + id: "dashboard-agent-panel", + default: "380px", + min: "320px", + max: "720px", + collapsible: true, + collapsed: !rightPanel, + collapsedSize: "0px", + }, + agentChild + ) + ); +} + +describe("DashboardAgent's real grid tree never remounts the chat across mode switches", () => { + it("keeps the agent panel's child mounted across floating/rightPanel/fullscreen transitions", () => { + let mounts = 0; + function Marker() { + useEffect(() => { + mounts += 1; + }, []); + return null; + } + + // jsdom reports every rect as 0x0; the library divides by the group's measured + // width when a mode switch changes a panel's size, so it needs a non-zero stand-in. + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ width: 1000, height: 600, x: 0, y: 0, top: 0, left: 0 } as DOMRect); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const sequence: DashboardAgentMode[] = [ + "floating", + "rightPanel", + "floating", + "fullscreen", + "rightPanel", + ]; + try { + for (const mode of sequence) { + act(() => { + root!.render(renderDashboardAgentGridTree(mode === "rightPanel", createElement(Marker))); + }); + expect(mounts).toBe(1); + } + } finally { + rectSpy.mockRestore(); + } + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index 15f581ed310..1d6138c2a8b 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -1,29 +1,86 @@ -// Both class helpers apply to always-rendered wrappers, so toggling fullscreen is a +// Both class helpers apply to always-rendered wrappers, so switching display mode is a // class change only and the open chat's transport, session and transcript survive it. +import { useMemo, useRef, useState, type CSSProperties } from "react"; +import { motion, type PanInfo } from "framer-motion"; +import { + draggableResizeHandleClassName, + useDraggableResizable, + type PanHandlerProps, + type ResizeEdge, +} from "~/components/primitives/DraggableResizable"; import { cn } from "~/utils/cn"; +// Mark an element (e.g. a header button, or just its icon) with `data-agent-no-drag` so a +// pan starting on it never drags the window. +const NO_DRAG_SELECTOR = "[data-agent-no-drag]"; + +/** Spread onto the drag handle; `dragHandleClassName` already carries cursor/touch-action/select-none. */ +export type FloatingDragProps = { + dragHandleProps: Partial; + dragHandleClassName: string; +}; + const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen"; +const AGENT_MODE_STORAGE_KEY = "tdev:dashboard-agent:mode"; + +export type DashboardAgentMode = "floating" | "rightPanel" | "fullscreen"; -export function readAgentFullscreen(): boolean { - if (typeof window === "undefined") return false; +// V1 floating window: FLOATING_WIDTH x FLOATING_HEIGHT, bottom-right, matching the +// gallery's own panel frame. +export const FLOATING_WIDTH = 380; +export const FLOATING_HEIGHT = 600; +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"]; + +// A dropped key (not just `undefined`) doesn't reliably clear on every style-application +// layer, so docked/fullscreen explicitly resets every key the floating rect ever sets. +const CLEARED_FLOATING_STYLE: CSSProperties = { + position: undefined, + left: undefined, + top: undefined, + width: undefined, + height: undefined, +}; + +export 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, + }; +} + +// Reads the old boolean key once, so a browser that only ever knew fullscreen keeps its +// choice after the upgrade to three modes. +export function readAgentMode(): DashboardAgentMode { + if (typeof window === "undefined") return "floating"; try { - return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true"; + const stored = window.localStorage.getItem(AGENT_MODE_STORAGE_KEY); + if (stored === "floating" || stored === "rightPanel" || stored === "fullscreen") return stored; + return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true" + ? "fullscreen" + : "floating"; } catch { - return false; + return "floating"; } } -export function writeAgentFullscreen(fullscreen: boolean): void { +export function writeAgentMode(mode: DashboardAgentMode): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(AGENT_FULLSCREEN_STORAGE_KEY, fullscreen ? "true" : "false"); + window.localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode); } catch { /* ignore */ } } -export function agentTakeoverClassName(fullscreen: boolean): string { - return fullscreen ? "absolute inset-0 z-10 bg-background-bright" : "h-full"; +function agentTakeoverClassName(fullscreen: boolean): string { + return fullscreen ? "absolute inset-0 z-10 flex flex-col bg-background-bright" : "h-full"; } // `invisible` rather than `display: none`: only this preserves the computed layout, so @@ -32,6 +89,105 @@ export function agentHiddenContentClassName(fullscreen: boolean): string { return cn("h-full overflow-hidden", fullscreen && "invisible"); } +/** + * Owns the drag-vs-click filter, so the panel and the standalone story behave identically. + * Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`, supplied by the caller. + * Always mounted as the sole wrapper of `children` across all three modes — the caller must + * never branch its own tree around this component, or a mode switch remounts the chat. + */ +export function FloatingAgentWindow({ + mode, + children, +}: { + mode: DashboardAgentMode; + children: (drag: FloatingDragProps) => React.ReactNode; +}) { + const fullscreen = mode === "fullscreen"; + const docked = mode === "rightPanel"; + const initial = useMemo(() => initialFloatingRect(), []); + const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({ + initial, + minSize: FLOATING_MIN_SIZE, + viewportPadding: FLOATING_MARGIN, + }); + const [dragging, setDragging] = useState(false); + // onPan can arrive before onPanStart, so the no-drag check runs once, on whichever fires first. + const gestureClassified = useRef(false); + const ignoringGesture = useRef(false); + + const classifyGesture = (event: PointerEvent) => { + if (gestureClassified.current) return; + gestureClassified.current = true; + ignoringGesture.current = !!(event.target as HTMLElement | null)?.closest(NO_DRAG_SELECTOR); + }; + + // Same shape as `dragHandleProps` below empty, so a mode with no drag doesn't change types. + const filteredDragHandleProps: Partial = + fullscreen || docked + ? {} + : { + onPanStart: (event: PointerEvent, info: PanInfo) => { + classifyGesture(event); + if (ignoringGesture.current) return; + setDragging(true); + dragHandleProps.onPanStart?.(event, info); + }, + onPan: (event: PointerEvent, info: PanInfo) => { + classifyGesture(event); + if (ignoringGesture.current) return; + dragHandleProps.onPan?.(event, info); + }, + onPanEnd: (event: PointerEvent, info: PanInfo) => { + gestureClassified.current = false; + ignoringGesture.current = false; + setDragging(false); + dragHandleProps.onPanEnd?.(event, info); + }, + }; + + // Same two-`div` shape in all three modes — only classes/style change — so switching `mode` + // never unmounts `children`; only className/style differ. + return ( + + {/* Clips content to the rounded corners without clipping the resize handles below, + which sit half outside this box's edges. */} + + {/* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */} + {children({ + dragHandleProps: filteredDragHandleProps, + dragHandleClassName: + fullscreen || docked + ? "" + : cn("select-none touch-none", dragging ? "cursor-grabbing" : "cursor-grab"), + })} + + {!fullscreen && + !docked && + RESIZE_EDGES.map((edge) => ( + + ))} + + ); +} + export function AgentPanelColumn({ fullscreen, children, diff --git a/apps/webapp/app/components/dashboard-agent/progress-line.ts b/apps/webapp/app/components/dashboard-agent/progress-line.ts index 0f9c1bcd95e..f853ab94af1 100644 --- a/apps/webapp/app/components/dashboard-agent/progress-line.ts +++ b/apps/webapp/app/components/dashboard-agent/progress-line.ts @@ -106,6 +106,13 @@ export function inFlightToolName(messages: ReadonlyArray): stri return null; } +/** A prose-only turn has no tool part to catch; a `text` part mid-stream has `state: "streaming"`. */ +export function hasUnfinishedTextPart(messages: ReadonlyArray): boolean { + const last = messages[messages.length - 1]; + if (!last || last.role !== "assistant") return false; + return partsOf(last).some((part) => part?.type === "text" && part.state === "streaming"); +} + /** Must stay non-null for the whole in-flight period: null unmounts, and a gap blinks. */ export function liveProgress( messages: ReadonlyArray, diff --git a/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts b/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts new file mode 100644 index 00000000000..8efb1194726 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { chatSessionsOption } from "./DashboardAgentChat"; +import { resolveOpenedChat } from "./opened-chat"; + +const CHAT_ID = "chat_abc123"; + +// A tool call the stream died on: still `input-available`, no result part. +const unfinishedMessage = { + id: "msg_2", + role: "assistant", + parts: [{ type: "tool-run_query", state: "input-available" }], +}; + +describe("the transport's sessions option, built from a real resolveOpenedChat result", () => { + it("marks isStreaming when the reopened chat's transcript still looks mid-turn", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [unfinishedMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + if (opened.kind !== "chat") throw new Error("expected a chat"); + + const sessions = chatSessionsOption(CHAT_ID, opened.session, opened.streaming); + + expect(sessions?.[CHAT_ID]?.isStreaming).toBe(true); + }); + + it("does not mark isStreaming once the transcript has settled", () => { + const settledMessage = { id: "msg_1", role: "user", parts: [{ type: "text", text: "hi" }] }; + const opened = resolveOpenedChat(CHAT_ID, { + messages: [settledMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + if (opened.kind !== "chat") throw new Error("expected a chat"); + + const sessions = chatSessionsOption(CHAT_ID, opened.session, opened.streaming); + + expect(sessions?.[CHAT_ID]?.isStreaming).toBe(false); + }); + + it("omits the session entirely when there is none to resume", () => { + expect(chatSessionsOption(CHAT_ID, null, true)).toBeUndefined(); + }); +}); + +// Structural: a live SSE resume is impractical in jsdom, so the teardown decision is +// pinned by source instead of driven end to end. +describe("the chat's teardown decision, source-checked", () => { + const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8"); + const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8"); + + it("has exactly one `transport.stopGeneration(` call site, gated by teardownCancelsTurn", () => { + const occurrences = [...chat.matchAll(/transport\.stopGeneration\(/g)]; + expect(occurrences).toHaveLength(1); + expect(chat).toContain("if (!teardownCancelsTurn(reason)) return;"); + }); + + it("passes the opened chat's streaming flag through to the mounted chat", () => { + expect(panel).toContain("setActive({ ...opened, organizationId: organization.id });"); + expect(panel).toContain("streaming={active.streaming}"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts b/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts index 1adcb6efe58..eafb42b8c16 100644 --- a/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts +++ b/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts @@ -116,6 +116,27 @@ describe("replacing a stale running step from the re-read", () => { expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]); expect(merged[0]).toBe(RUNNING_STEP); }); + + // A prose-only turn: no tool part, just a `text` part the stream never marked done. + const RUNNING_TEXT = { + id: "msg_text", + role: "assistant", + parts: [{ type: "text", text: "Concurrency on the ", state: "streaming" }], + }; + + const FINISHED_TEXT = { + id: "msg_text", + role: "assistant", + parts: [ + { type: "text", text: "Concurrency on the `emails` queue hit its limit.", state: "done" }, + ], + }; + + it("swaps a still-streaming text part for its settled version too", () => { + const merged = mergeSettledMessages([RUNNING_TEXT], [FINISHED_TEXT]); + expect(merged).toEqual([FINISHED_TEXT]); + expect(transcriptLooksUnfinished(merged)).toBe(false); + }); }); describe("reading the transcript endpoint", () => { @@ -171,10 +192,26 @@ describe("deciding whether a settled turn is worth re-reading", () => { parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }], }; + // A prose-only reply: no tool part to catch, just a `text` part still streaming. + const DANGLING_TEXT = { + id: "msg_dangling_text", + role: "assistant", + parts: [{ type: "text", text: "Concurrency on the ", state: "streaming" }], + }; + it("re-reads when the stream died mid-tool, not only when a card is open", () => { expect(transcriptLooksUnfinished([DANGLING_TOOL])).toBe(true); }); + it("re-reads when the stream died mid-text, with no tool part at all", () => { + expect(transcriptLooksUnfinished([DANGLING_TEXT])).toBe(true); + }); + + it("leaves a finished text part alone", () => { + const finished = { ...DANGLING_TEXT, parts: [{ type: "text", text: "Done.", state: "done" }] }; + expect(transcriptLooksUnfinished([finished])).toBe(false); + }); + it("re-reads while a card is still open", () => { expect(transcriptLooksUnfinished([OPEN])).toBe(true); }); diff --git a/apps/webapp/app/components/dashboard-agent/settled-transcript.ts b/apps/webapp/app/components/dashboard-agent/settled-transcript.ts index 187d67f5588..61a8ff885a4 100644 --- a/apps/webapp/app/components/dashboard-agent/settled-transcript.ts +++ b/apps/webapp/app/components/dashboard-agent/settled-transcript.ts @@ -1,4 +1,9 @@ -import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./progress-line"; +import { + hasUnfinishedTextPart, + IN_FLIGHT_TOOL_STATES, + inFlightToolName, + liveInvestigation, +} from "./progress-line"; /** * Re-reading the stored transcript once a turn settles. @@ -11,15 +16,16 @@ import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./pr type Identified = { id: string }; -/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */ +/** A message whose stream died mid-tool or mid-text: a part still reads as running. */ function stillRunning(message: unknown): boolean { const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts; if (!Array.isArray(parts)) return false; return parts.some( (part) => - typeof part?.type === "string" && - part.type.startsWith("tool-") && - IN_FLIGHT_TOOL_STATES.has(part.state ?? "") + (typeof part?.type === "string" && + part.type.startsWith("tool-") && + IN_FLIGHT_TOOL_STATES.has(part.state ?? "")) || + (part?.type === "text" && part.state === "streaming") ); } @@ -65,7 +71,11 @@ export function hasOpenInvestigation(messages: ReadonlyArray): boolean * not the only shape a re-read has to recover from. */ export function transcriptLooksUnfinished(messages: ReadonlyArray): boolean { - return hasOpenInvestigation(messages) || inFlightToolName(messages as never) !== null; + return ( + hasOpenInvestigation(messages) || + inFlightToolName(messages as never) !== null || + hasUnfinishedTextPart(messages as never) + ); } /** diff --git a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx index 976d7f25d55..caf0533d1e6 100644 --- a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx +++ b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx @@ -25,7 +25,14 @@ 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; + +/** Shared so anything else drawing on this grid stays visually identical to the shape library. */ +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/components/primitives/DraggableResizable.dom.test.ts b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts new file mode 100644 index 00000000000..50e1d360f42 --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +// Framer-motion can deliver onPan before onPanStart; drives the real handlers to prove +// the hook survives that ordering (draggableResizableMath.test.ts only covers the math). +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 set too (framer always sends both), so a reverted implementation fails 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/app/components/primitives/DraggableResizable.tsx b/apps/webapp/app/components/primitives/DraggableResizable.tsx new file mode 100644 index 00000000000..bdf9c7b2224 --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { type PanInfo } from "framer-motion"; +import { cn } from "~/utils/cn"; +import { + applyDragDelta, + applyResizeDelta, + clampPosition, + clampRectToViewport, + clampSize, + type Point, + type Rect, + type ResizeEdge, + type Size, + type Viewport, +} 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 from state; `x`/`y` are the top-left corner in viewport coordinates. */ + style: CSSProperties; + dragHandleProps: PanHandlerProps; + resizeHandleProps: (edge: ResizeEdge) => PanHandlerProps; + position: Point; + size: Size; +}; + +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 }; +} + +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 }; + }); + + // 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]); + + // Folds `info.delta` via functional setState, no gesture-start baseline: onPan can + // arrive before onPanStart, which would make a ref-based baseline stale. + const dragHandleProps: PanHandlerProps = { + onPanStart: () => {}, + onPan: (_event, info: PanInfo) => { + setRect((current) => applyDragDelta(current, info.delta, getViewport(), viewportPadding)); + }, + onPanEnd: () => {}, + }; + + const resizeHandleProps = (edge: ResizeEdge): PanHandlerProps => ({ + onPanStart: () => {}, + onPan: (_event, info: PanInfo) => { + setRect((current) => + applyResizeDelta( + edge, + current, + info.delta, + minSize, + maxSize, + getViewport(), + viewportPadding + ) + ); + }, + onPanEnd: () => {}, + }); + + 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/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 0f1a82df3a6..2d178dc62af 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -223,7 +223,11 @@ function PopoverArrowTrigger({ > {children} - + {/* `data-agent-no-drag`: an opt-out marker draggable-window hosts check via closest(). + `contents` keeps this wrapper invisible to layout. */} + + + ); } 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..5c9d5f6177c --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import { + applyDragDelta, + applyResizeDelta, + clamp, + clampPosition, + clampRectToViewport, + clampSize, + resizeRect, + type Rect, +} 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 }; + // 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, 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, 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, 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, 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, undefined, viewport, padding)).toEqual({ + x: 120, + y: 120, + w: 280, + h: 180, + }); + expect(resizeRect("se", start, -20, -20, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 100, + w: 280, + h: 180, + }); + }); + + it("respects min size when shrinking past it", () => { + 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, 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, 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); + }); +}); + +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); + }); +}); + +// onPan can arrive before onPanStart, so these prove the delta-folding approach has no +// baseline to go stale across back-to-back gestures. +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 }); + + // 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); + }); + + 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 new file mode 100644 index 00000000000..dfeecd2ca8e --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -0,0 +1,118 @@ +// 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 }; +} + +// North/west edges move the opposite corner too, so the cap is derived from the fixed far +// edge and growth can't push it past the viewport padding. +export function resizeRect( + edge: ResizeEdge, + start: Rect, + dx: number, + dy: number, + minSize: Size, + maxSize: Size | undefined, + viewport: Viewport, + padding: number +): 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.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.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.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.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); + } + + return { x, y, w, h }; +} + +// Incremental (framer's per-event `delta`), not start-snapshot-based, since onPan can +// arrive before onPanStart and leave a snapshot baseline stale. +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); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index 4870b53a6d5..bb99835f1b0 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -86,10 +86,14 @@ 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: "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.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index c8359ceba34..e14d8333b87 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -13,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. @@ -180,6 +183,88 @@ function DotMatrixTab() { ))} + + + + + ); +} + +// 1.5x the 32px candidate icons this replaced. +const EDITOR_SIZE = 48; + +/** 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, + onToggle, +}: { + lit: boolean[]; + size: number; + 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} + {...(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); + }, + })} + /> + ); + })} + + ); +} + +/** 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 toggle = (index: number) => { + setLit((current) => current.map((value, i) => (i === index ? !value : value))); + }; + + const rows = Array.from({ length: MATRIX }, (_, r) => + Array.from({ length: MATRIX }, (_, c) => (lit[r * MATRIX + c] ? "#" : ".")).join("") + ); + + return ( + + + + {rows.join("\n")} + ); } 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..14e1d7e616f --- /dev/null +++ b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx @@ -0,0 +1,97 @@ +import { useLocation } from "@remix-run/react"; +import { motion } from "framer-motion"; +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"; +import type { DashboardAgentChat } from "~/components/dashboard-agent/DashboardAgentHistory"; +import { + FloatingAgentWindow, + type DashboardAgentMode, +} 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[] = []; + +/** Static content only: this demos the shell (drag, resize, fullscreen), not a live backend. */ +export default function Story() { + const [open, setOpen] = useState(true); + const [mode, setMode] = useState("floating"); + const closeWindow = () => { + setOpen(false); + setMode("floating"); + }; + + // 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: closes the demo on route change. The real chat intentionally persists. + const { pathname } = useLocation(); + const previousPathname = useRef(pathname); + useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; + closeWindow(); + }, [pathname]); + + 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. + + + {mounted && !open && ( + + setOpen(true)}> + Open chat + + + )} + {mounted && open && ( + + {({ dragHandleProps, dragHandleClassName }) => ( + + + {}} + onSelectChat={() => {}} + onDeleteChat={() => {}} + mode={mode} + onModeChange={setMode} + onClose={closeWindow} + /> + + + + + + + + + + + )} + + )} + + ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 35f51682938..09e6bcf0639 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -131,6 +131,7 @@ 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" }, 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/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", ], 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: {}
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts new file mode 100644 index 00000000000..5c4beb07fbb --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.dom.test.ts @@ -0,0 +1,91 @@ +// @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, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { ModeToggle } from "./DashboardAgentHeader"; + +function withProviders(children: React.ReactNode) { + return createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement(ShortcutsProvider, null, children) + ); +} + +let container: HTMLDivElement | undefined; +let root: Root | undefined; + +afterEach(() => { + if (root) { + act(() => root!.unmount()); + } + container?.remove(); + container = undefined; + root = undefined; +}); + +function renderToggle(mode: "floating" | "rightPanel" | "fullscreen", onModeChange: () => void) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode, onModeChange }))); + }); + return container; +} + +// Row-reverse layout keeps the trigger as the first button in DOM order. +function getTrigger(el: HTMLElement) { + return el.querySelectorAll("button")[0] as HTMLButtonElement; +} + +function expandToggle(el: HTMLElement) { + const trigger = getTrigger(el); + act(() => { + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("ModeToggle", () => { + it("collapses on Escape without changing mode, and marks the event handled", () => { + const onModeChange = vi.fn(); + const el = renderToggle("floating", onModeChange); + expandToggle(el); + expect(getTrigger(el).getAttribute("aria-expanded")).toBe("true"); + + // cancelable: true, like a real native keydown; otherwise preventDefault() is a no-op. + const escapeEvent = new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }); + act(() => { + document.dispatchEvent(escapeEvent); + }); + + expect(escapeEvent.defaultPrevented).toBe(true); + expect(getTrigger(el).getAttribute("aria-expanded")).toBe("false"); + expect(onModeChange).not.toHaveBeenCalled(); + }); + + it("collapses when mode changes externally", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const onModeChange = vi.fn(); + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode: "floating", onModeChange }))); + }); + expandToggle(container); + expect(getTrigger(container).getAttribute("aria-expanded")).toBe("true"); + + act(() => { + root!.render(withProviders(createElement(ModeToggle, { mode: "fullscreen", onModeChange }))); + }); + + expect(getTrigger(container).getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx index 1b6e5bb9db3..54bab3535eb 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx @@ -1,7 +1,9 @@ -import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; -import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { ChatFloatingPanel } from "~/assets/icons/ChatFloatingPanel"; +import { ChatFullScreen } from "~/assets/icons/ChatFullScreen"; +import { ChatRightPanel } from "~/assets/icons/ChatRightPanel"; import { CrossIcon } from "~/assets/icons/CrossIcon"; -import { PlusIcon } from "~/assets/icons/PlusIcon"; import { Button } from "~/components/primitives/Buttons"; import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; @@ -12,6 +14,14 @@ import { type DashboardAgentChat, } from "./DashboardAgentHistory"; import { chatHistoryTriggerLabel } from "./header-labels"; +import type { DashboardAgentMode } from "./panel-layout"; + +const MODE_OPTIONS: { mode: DashboardAgentMode; label: string; Icon: typeof ChatFloatingPanel }[] = + [ + { mode: "floating", label: "Floating", Icon: ChatFloatingPanel }, + { mode: "rightPanel", label: "Right panel", Icon: ChatRightPanel }, + { mode: "fullscreen", label: "Fullscreen", Icon: ChatFullScreen }, + ]; // Display only. The key is registered once, in `DashboardAgent`; registering it // anywhere else makes the keystroke fire twice. @@ -21,31 +31,119 @@ export const NEW_CHAT_SHORTCUT: Shortcut = { enabledOnInputElements: true, }; +export function ModeToggle({ + mode, + onModeChange, +}: { + mode: DashboardAgentMode; + onModeChange: (mode: DashboardAgentMode) => void; +}) { + const [isExpanded, setExpanded] = useState(false); + const containerRef = useRef(null); + const triggerRef = useRef(null); + const currentOption = MODE_OPTIONS.find((option) => option.mode === mode) ?? MODE_OPTIONS[0]; + const otherOptions = MODE_OPTIONS.filter((option) => option.mode !== mode); + + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- collapses when `mode` changes externally (e.g. route-driven). + setExpanded(false); + }, [mode]); + + // Button doesn't forward arbitrary aria props, so set them directly on the node. + useEffect(() => { + const el = triggerRef.current; + if (!el) return; + el.setAttribute("aria-haspopup", "true"); + el.setAttribute("aria-expanded", String(isExpanded)); + }, [isExpanded]); + + useEffect(() => { + if (!isExpanded) return; + + function handlePointerDown(event: PointerEvent) { + if (!containerRef.current?.contains(event.target as Node)) { + setExpanded(false); + } + } + + // Capture phase + preventDefault: DashboardAgentPanel's own Escape handler + // (bubble phase) checks defaultPrevented before closing the whole panel. + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + setExpanded(false); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown, true); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isExpanded]); + + return ( + + setExpanded((open) => !open)} + LeadingIcon={} + /> + + {isExpanded && + otherOptions.map(({ mode: option, label, Icon }) => ( + + { + onModeChange(option); + setExpanded(false); + }} + LeadingIcon={} + /> + + ))} + + + ); +} + export function DashboardAgentHeader({ title, chats, currentChatId, thinkingChatId, - onNewChat, - showNewChat, onOpenHistory, onSelectChat, onDeleteChat, - onToggleFullscreen, - isFullscreen, + mode, + onModeChange, onClose, }: { title: string; chats: DashboardAgentChat[]; currentChatId: string; thinkingChatId?: string | null; - onNewChat: () => void; - showNewChat: boolean; onOpenHistory: () => void; onSelectChat: (chatId: string) => void; onDeleteChat: (chatId: string) => void; - onToggleFullscreen: () => void; - isFullscreen: boolean; + mode: DashboardAgentMode; + onModeChange: (mode: DashboardAgentMode) => void; onClose: () => void; }) { const [isHistoryOpen, setHistoryOpen] = useState(false); @@ -103,36 +201,8 @@ export function DashboardAgentHeader({ onConfirm={onDeleteChat} /> - - {showNewChat && ( - - New chat - - - } - onClick={onNewChat} - LeadingIcon={} - /> - )} - - ) : ( - - ) - } - /> + + void; - isFullscreen?: boolean; - onToggleFullscreen?: () => void; + mode?: DashboardAgentMode; + onModeChange?: (mode: DashboardAgentMode) => void; + /** Spread onto the header, which is the floating window's drag handle; already filtered by `FloatingAgentWindow`. */ + dragHandleProps?: Partial; + dragHandleClassName?: string; // Every `seq` below distinguishes repeat requests with identical contents. requestedMessage?: { text: string; seq: number }; openChatRequest?: { chatId: string; seq: number }; @@ -127,7 +134,6 @@ export function DashboardAgentPanel({ const [loading, setLoading] = useState( () => readLastChat(storageKey)?.path === location.pathname ); - const currentPage = agentPageLabel(pageContext, location.pathname); const pagePaths = useMemo>( @@ -589,7 +595,7 @@ export function DashboardAgentPanel({ return ( { if ( @@ -604,23 +610,23 @@ export function DashboardAgentPanel({ onClose(); }} > - {})} - isFullscreen={isFullscreen} - onClose={onClose} - /> + + {})} + onClose={onClose} + /> + {/* Always mounted, so the chat keeps its transport, session and transcript. */} - + {loading ? ( @@ -655,6 +661,8 @@ export function DashboardAgentPanel({ onTurnSettled={loadHistory} onActivityChange={handleActivityChange} onQuotaChange={handleQuotaChange} + onNewChat={newChat} + showNewChat={active !== null} /> ) : ( } fullWidth={fullWidth} textAlignLeft={fullWidth} className={className} diff --git a/apps/webapp/app/components/dashboard-agent/WatchButton.tsx b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx index 25b000cd656..100d9ecb975 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchButton.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchButton.tsx @@ -1,5 +1,5 @@ -import { EyeIcon } from "@heroicons/react/20/solid"; import type { WatchSpec } from "@internal/dashboard-agent-contracts"; +import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { Button } from "~/components/primitives/Buttons"; import { useDashboardAgent } from "./dashboardAgentLauncher"; import { watchTooltipLabel } from "~/presenters/v3/dashboardAgent"; @@ -31,8 +31,7 @@ export function WatchButton({ } fullWidth={fullWidth} textAlignLeft={fullWidth} className={className} diff --git a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx index 551a9683ef5..6b8f9abe907 100644 --- a/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx +++ b/apps/webapp/app/components/dashboard-agent/WatchResultBlock.tsx @@ -10,31 +10,31 @@ * append time by `app/presenters/v3/dashboardAgent`, so a later copy change never rewrites what * a user was already told. */ -import { CheckCircleIcon, EyeIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; +import { CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; import type { WatchResultBlock as WatchResultBlockPayload } from "@internal/dashboard-agent-contracts"; +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 eye; the check belongs to the one-shot that did - * answer the question. - */ +/** `watching` is a live watch, so it gets the chat's spinner; the terminal outcomes keep static icons. */ const OUTCOME = { - watching: { label: "Watch", Icon: EyeIcon, tone: "neutral" }, - already_true: { label: "Watch", Icon: CheckCircleIcon, tone: "success" }, - impossible: { label: "Watch", Icon: InformationCircleIcon, tone: "neutral" }, + watching: { label: "Watch", icon: }, + already_true: { + label: "Watch", + icon: , + }, + impossible: { + label: "Watch", + icon: , + }, } as const; export function WatchResultBlock({ block }: { block: WatchResultBlockPayload }) { - const { label, Icon, tone } = OUTCOME[block.outcome] ?? OUTCOME.watching; + const { label, icon } = OUTCOME[block.outcome] ?? OUTCOME.watching; return ( - } - > + {block.headline} {block.lifetime ? {block.lifetime} : null} {block.detail ? {block.detail} : null} diff --git a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx index 052824aa25c..e623565fb15 100644 --- a/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx +++ b/apps/webapp/app/components/dashboard-agent/dashboardAgentLauncher.tsx @@ -46,29 +46,31 @@ 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, 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 ? ( + "Close chat" + ) : ( + + Open chat + + + ) } button={ setOpen(true)} + aria-label={open ? "Close chat" : hasUnread ? `${ASK_AGENT_LABEL}, unread updates` : ASK_AGENT_LABEL} + onClick={() => setOpen(!open)} > {ASK_AGENT_LABEL} diff --git a/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts b/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts new file mode 100644 index 00000000000..e1ec956ac9c --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/floating-window-mode.test.ts @@ -0,0 +1,41 @@ +// Guards the tree-shape invariant: DashboardAgent.tsx must mount FloatingAgentWindow +// exactly once, never branch it behind a mode check, and gate the right-column sizing +// on `mode === "rightPanel"` rather than swapping in a whole separate element tree. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const DIR = __dirname; + +function read(file: string): string { + return readFileSync(join(DIR, file), "utf8"); +} + +describe("DashboardAgent.tsx keeps one tree shape across display modes", () => { + const source = read("DashboardAgent.tsx"); + + it("mounts FloatingAgentWindow exactly once, unconditionally", () => { + const occurrences = source.match(/ { + expect(source).toContain("FloatingAgentWindow"); + }); + + it('gates the right-column sizing on mode === "rightPanel", not a branch around FloatingAgentWindow', () => { + expect(source).toContain('collapsed={mode !== "rightPanel"}'); + }); + + it("keeps ResizableHandle always mounted — a conditional handle shifts sibling keys and remounts the chat", () => { + const occurrences = source.match(/ { + expect(source).toContain('"overflow-visible!"'); + expect(source).not.toContain('"!overflow-visible"'); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts b/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts index 0f38b54f752..6b05b925d1d 100644 --- a/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts +++ b/apps/webapp/app/components/dashboard-agent/opened-chat.test.ts @@ -10,18 +10,37 @@ const message: UIMessage = { parts: [{ type: "text", text: "why did this run fail?" }], }; +// A tool call the stream died on: still `input-available`, no result part. +const unfinishedMessage: UIMessage = { + id: "msg_2", + role: "assistant", + parts: [{ type: "tool-run_query", state: "input-available" } as never], +}; + describe("resolveOpenedChat", () => { it("opens a chat that has messages", () => { const opened = resolveOpenedChat(CHAT_ID, { messages: [message], session: null }); - expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [message], session: null }); + expect(opened).toEqual({ + kind: "chat", + chatId: CHAT_ID, + messages: [message], + session: null, + streaming: false, + }); }); it("still opens a chat that exists but has no messages", () => { const opened = resolveOpenedChat(CHAT_ID, { messages: [], session: null }); expect(opened.kind).toBe("chat"); - expect(opened).toEqual({ kind: "chat", chatId: CHAT_ID, messages: [], session: null }); + expect(opened).toEqual({ + kind: "chat", + chatId: CHAT_ID, + messages: [], + session: null, + streaming: false, + }); }); it("treats a chat with no messages field the same way", () => { @@ -54,4 +73,23 @@ describe("resolveOpenedChat", () => { it("has no session when the token is missing", () => { expect(resolveOpenedChat(CHAT_ID, { messages: [message] })).toMatchObject({ session: null }); }); + + // The bug this guards: closing mid-turn and reopening must resume, not show a stalled turn. + it("marks streaming when the fetched transcript still looks mid-turn", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [message, unfinishedMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + + expect(opened).toMatchObject({ streaming: true }); + }); + + it("is not streaming once the transcript settles", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [message], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + + expect(opened).toMatchObject({ streaming: false }); + }); }); diff --git a/apps/webapp/app/components/dashboard-agent/opened-chat.ts b/apps/webapp/app/components/dashboard-agent/opened-chat.ts index f9dd64f89e9..404145eb718 100644 --- a/apps/webapp/app/components/dashboard-agent/opened-chat.ts +++ b/apps/webapp/app/components/dashboard-agent/opened-chat.ts @@ -1,4 +1,5 @@ import type { UIMessage } from "@ai-sdk/react"; +import { transcriptLooksUnfinished } from "./settled-transcript"; export type OpenedChatResponse = { messages?: UIMessage[]; @@ -11,6 +12,8 @@ export type OpenedChat = chatId: string; messages: UIMessage[]; session: { publicAccessToken: string; lastEventId?: string } | null; + // True if the transcript still reads as mid-turn, so the transport resumes it. + streaming: boolean; } // Deleted, or belonging to someone else: the read failed, so there is no chat to show. | { kind: "gone" }; @@ -23,15 +26,17 @@ export function resolveOpenedChat( if (!response) return { kind: "gone" }; const session = response.session; + const messages = response.messages ?? []; return { kind: "chat", chatId, - messages: response.messages ?? [], + messages, session: session?.publicAccessToken ? { publicAccessToken: session.publicAccessToken, lastEventId: session.lastEventId ?? undefined, } : null, + streaming: transcriptLooksUnfinished(messages), }; } 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..58c5c1c15c2 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.dom.test.ts @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +import { Panel, PanelGroup, PanelResizer } from "@window-splitter/react"; +import { createElement, useEffect, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PanInfo } from "framer-motion"; +import { useDraggableResizable } from "~/components/primitives/DraggableResizable"; +import { + FLOATING_HEIGHT, + FLOATING_MARGIN, + FLOATING_MIN_SIZE, + FLOATING_WIDTH, + FloatingAgentWindow, + initialFloatingRect, + type DashboardAgentMode, + type FloatingDragProps, +} from "./panel-layout"; + +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; + +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, + }); + }); +}); + +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); + }); +}); + +// Mirrors the real header: a title-like element (draggable) beside a +// `data-agent-no-drag` action (opted out), same as DashboardAgentHeader's button group. +function renderFloatingAgentWindow() { + let latest!: FloatingDragProps; + function Harness() { + return createElement(FloatingAgentWindow, { mode: "floating" }, (drag: FloatingDragProps) => { + // oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value. + latest = drag; + return createElement( + "div", + null, + createElement("span", { "data-testid": "title" }, "Chat title"), + createElement("button", { "data-agent-no-drag": "", "data-testid": "action" }, "Close") + ); + }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness)); + }); + return { + get dragHandleProps() { + return latest.dragHandleProps; + }, + outerLeft: () => (container!.firstElementChild as HTMLDivElement).style.left, + titleEl: () => container!.querySelector('[data-testid="title"]')!, + actionEl: () => container!.querySelector('[data-testid="action"]')!, + }; +} + +describe("FloatingAgentWindow's drag-vs-click filter", () => { + it("drags when a gesture starts on ordinary content, like the header title", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.titleEl() as unknown as PointerEvent["target"]; + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).not.toBe(startLeft); + }); + + it("does not drag when a gesture starts on a data-agent-no-drag element", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.actionEl() as unknown as PointerEvent["target"]; + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).toBe(startLeft); + }); + + it("does not leak a delta when onPan for a no-drag target lands before its onPanStart", () => { + stubViewport(1200, 900); + const view = renderFloatingAgentWindow(); + const startLeft = view.outerLeft(); + + act(() => { + const target = view.actionEl() as unknown as PointerEvent["target"]; + // Framer-motion's real ordering: onPan can arrive first. + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + view.dragHandleProps.onPanStart!({ target } as PointerEvent, fakePanInfo(0, 0)); + view.dragHandleProps.onPan!({ target } as PointerEvent, fakePanInfo(-20, 0)); + }); + + expect(view.outerLeft()).toBe(startLeft); + }); +}); + +describe("FloatingAgentWindow keeps its child mounted across every mode transition", () => { + it("never remounts the child across any of the three modes (same tree shape always)", () => { + let mounts = 0; + function Marker() { + useEffect(() => { + mounts += 1; + }, []); + return null; + } + function Harness({ mode }: { mode: DashboardAgentMode }) { + return createElement(FloatingAgentWindow, { mode }, () => createElement(Marker)); + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + // Every pairwise transition among the three modes, in both directions. + const sequence: DashboardAgentMode[] = [ + "floating", + "rightPanel", + "floating", + "fullscreen", + "rightPanel", + "fullscreen", + "floating", + ]; + for (const mode of sequence) { + act(() => { + root!.render(createElement(Harness, { mode })); + }); + expect(mounts).toBe(1); + } + }); +}); + +describe("FloatingAgentWindow's fullscreen geometry", () => { + it("pins the exact takeover classes, including the flex column that fills the takeover's height", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(FloatingAgentWindow, { mode: "fullscreen" }, () => null)); + }); + const outer = container.firstElementChild as HTMLDivElement; + expect(outer.className).toBe("absolute inset-0 z-10 flex flex-col bg-background-bright"); + expect(outer.classList.contains("flex")).toBe(true); + expect(outer.classList.contains("flex-col")).toBe(true); + expect(outer.getAttribute("style")).toBeNull(); + }); +}); + +// Mirrors DashboardAgent.tsx's grid: a content panel, a handle only in rightPanel mode, +// and an agent panel that's either sized (rightPanel) or truly collapsed (otherwise). +// A leftover fixed-pixel track from a hidden-not-unmounted handle, or from a "0px" panel +// that doesn't actually collapse, pushes the grid past its own container's width. +// The handle is ALWAYS mounted (only its `size` varies) — PanelGroup keys children by +// index after dropping falsy ones (@window-splitter/react's useIndexedChildren), so a +// conditionally-rendered handle shifts the agent panel's key on every mode switch and +// remounts the whole chat subtree beneath it. +function renderDashboardAgentGrid(rightPanel: boolean) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( + createElement( + PanelGroup, + { orientation: "horizontal" }, + createElement(Panel, { id: "dashboard-content", min: "320px" }), + createElement(PanelResizer, { + id: "dashboard-agent-handle", + size: rightPanel ? "3px" : "0px", + }), + createElement(Panel, { + id: "dashboard-agent-panel", + default: "380px", + min: "320px", + max: "720px", + collapsible: true, + collapsed: !rightPanel, + collapsedSize: "0px", + }) + ) + ); + }); + return container.firstElementChild as HTMLElement; +} + +describe("FloatingAgentWindow clears floating geometry when it docks", () => { + it("leaves no stale position/left/top/width/height after a drag, then switching to rightPanel", () => { + stubViewport(1200, 900); + let latest!: FloatingDragProps; + function Harness({ mode }: { mode: DashboardAgentMode }) { + return createElement(FloatingAgentWindow, { mode }, (drag: FloatingDragProps) => { + // oxlint-disable-next-line react/globals -- test harness capturing the render-prop's value. + latest = drag; + return null; + }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Harness, { mode: "floating" })); + }); + act(() => { + latest.dragHandleProps.onPanStart!(fakeEvent, fakePanInfo(0, 0)); + latest.dragHandleProps.onPan!(fakeEvent, fakePanInfo(-40, -10)); + }); + + act(() => { + root!.render(createElement(Harness, { mode: "rightPanel" })); + }); + + const node = container.firstElementChild as HTMLDivElement; + expect(node.style.position).toBe(""); + expect(node.style.left).toBe(""); + expect(node.style.top).toBe(""); + expect(node.style.width).toBe(""); + expect(node.style.height).toBe(""); + }); + + // Mirrors FloatingAgentWindow's own style ternary directly against the resize path + // (which changes width/height, not just position — resizeHandleProps isn't exposed + // through the render prop, so this drives the same underlying hook instead). + it("leaves no stale geometry after a resize (width/height change), then docking", () => { + stubViewport(1200, 900); + let latest!: ReturnType; + // Matches FloatingAgentWindow's own fix: an explicit reset object, not `undefined` — + // a dropped style key isn't guaranteed to clear on every style-application layer. + const clearedStyle = { + position: undefined, + left: undefined, + top: undefined, + width: undefined, + height: undefined, + }; + function Mirror({ docked }: { docked: boolean }) { + // 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 createElement("div", { style: docked ? clearedStyle : latest.style }); + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render(createElement(Mirror, { docked: false })); + }); + act(() => latest.resizeHandleProps("e").onPan(fakeEvent, fakePanInfo(60, 0))); + + act(() => { + root!.render(createElement(Mirror, { docked: true })); + }); + + const node = container.firstElementChild as HTMLDivElement; + expect(node.style.position).toBe(""); + expect(node.style.width).toBe(""); + expect(node.style.height).toBe(""); + }); +}); + +describe("DashboardAgent's degenerate grid tracks outside rightPanel", () => { + it("keeps the handle mounted but collapses it and the agent panel to bare 0px tracks", () => { + const group = renderDashboardAgentGrid(false); + expect(group.querySelector('[data-splitter-type="handle"]')).not.toBeNull(); + const columns = group.style.gridTemplateColumns; + expect(columns.endsWith("0px")).toBe(true); + expect(columns).not.toMatch(/\b3px\b/); + }); + + it("keeps the handle and a real sized track in rightPanel mode", () => { + const group = renderDashboardAgentGrid(true); + expect(group.querySelector('[data-splitter-type="handle"]')).not.toBeNull(); + expect(group.style.gridTemplateColumns).toMatch(/\b3px\b/); + }); +}); + +// Mirrors DashboardAgent.tsx's real PanelGroup/Panel/PanelResizer shape (not the +// FloatingAgentWindow-standalone test above, which never puts a handle between the +// panels and so is blind to the sibling-key-shift bug this pins). +function renderDashboardAgentGridTree(rightPanel: boolean, agentChild: ReactNode) { + return createElement( + PanelGroup, + { orientation: "horizontal" }, + createElement(Panel, { id: "dashboard-content", min: "320px" }), + createElement(PanelResizer, { + id: "dashboard-agent-handle", + size: rightPanel ? "3px" : "0px", + }), + createElement( + Panel, + { + id: "dashboard-agent-panel", + default: "380px", + min: "320px", + max: "720px", + collapsible: true, + collapsed: !rightPanel, + collapsedSize: "0px", + }, + agentChild + ) + ); +} + +describe("DashboardAgent's real grid tree never remounts the chat across mode switches", () => { + it("keeps the agent panel's child mounted across floating/rightPanel/fullscreen transitions", () => { + let mounts = 0; + function Marker() { + useEffect(() => { + mounts += 1; + }, []); + return null; + } + + // jsdom reports every rect as 0x0; the library divides by the group's measured + // width when a mode switch changes a panel's size, so it needs a non-zero stand-in. + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue({ width: 1000, height: 600, x: 0, y: 0, top: 0, left: 0 } as DOMRect); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + const sequence: DashboardAgentMode[] = [ + "floating", + "rightPanel", + "floating", + "fullscreen", + "rightPanel", + ]; + try { + for (const mode of sequence) { + act(() => { + root!.render(renderDashboardAgentGridTree(mode === "rightPanel", createElement(Marker))); + }); + expect(mounts).toBe(1); + } + } finally { + rectSpy.mockRestore(); + } + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx index 15f581ed310..1d6138c2a8b 100644 --- a/apps/webapp/app/components/dashboard-agent/panel-layout.tsx +++ b/apps/webapp/app/components/dashboard-agent/panel-layout.tsx @@ -1,29 +1,86 @@ -// Both class helpers apply to always-rendered wrappers, so toggling fullscreen is a +// Both class helpers apply to always-rendered wrappers, so switching display mode is a // class change only and the open chat's transport, session and transcript survive it. +import { useMemo, useRef, useState, type CSSProperties } from "react"; +import { motion, type PanInfo } from "framer-motion"; +import { + draggableResizeHandleClassName, + useDraggableResizable, + type PanHandlerProps, + type ResizeEdge, +} from "~/components/primitives/DraggableResizable"; import { cn } from "~/utils/cn"; +// Mark an element (e.g. a header button, or just its icon) with `data-agent-no-drag` so a +// pan starting on it never drags the window. +const NO_DRAG_SELECTOR = "[data-agent-no-drag]"; + +/** Spread onto the drag handle; `dragHandleClassName` already carries cursor/touch-action/select-none. */ +export type FloatingDragProps = { + dragHandleProps: Partial; + dragHandleClassName: string; +}; + const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen"; +const AGENT_MODE_STORAGE_KEY = "tdev:dashboard-agent:mode"; + +export type DashboardAgentMode = "floating" | "rightPanel" | "fullscreen"; -export function readAgentFullscreen(): boolean { - if (typeof window === "undefined") return false; +// V1 floating window: FLOATING_WIDTH x FLOATING_HEIGHT, bottom-right, matching the +// gallery's own panel frame. +export const FLOATING_WIDTH = 380; +export const FLOATING_HEIGHT = 600; +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"]; + +// A dropped key (not just `undefined`) doesn't reliably clear on every style-application +// layer, so docked/fullscreen explicitly resets every key the floating rect ever sets. +const CLEARED_FLOATING_STYLE: CSSProperties = { + position: undefined, + left: undefined, + top: undefined, + width: undefined, + height: undefined, +}; + +export 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, + }; +} + +// Reads the old boolean key once, so a browser that only ever knew fullscreen keeps its +// choice after the upgrade to three modes. +export function readAgentMode(): DashboardAgentMode { + if (typeof window === "undefined") return "floating"; try { - return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true"; + const stored = window.localStorage.getItem(AGENT_MODE_STORAGE_KEY); + if (stored === "floating" || stored === "rightPanel" || stored === "fullscreen") return stored; + return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true" + ? "fullscreen" + : "floating"; } catch { - return false; + return "floating"; } } -export function writeAgentFullscreen(fullscreen: boolean): void { +export function writeAgentMode(mode: DashboardAgentMode): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(AGENT_FULLSCREEN_STORAGE_KEY, fullscreen ? "true" : "false"); + window.localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode); } catch { /* ignore */ } } -export function agentTakeoverClassName(fullscreen: boolean): string { - return fullscreen ? "absolute inset-0 z-10 bg-background-bright" : "h-full"; +function agentTakeoverClassName(fullscreen: boolean): string { + return fullscreen ? "absolute inset-0 z-10 flex flex-col bg-background-bright" : "h-full"; } // `invisible` rather than `display: none`: only this preserves the computed layout, so @@ -32,6 +89,105 @@ export function agentHiddenContentClassName(fullscreen: boolean): string { return cn("h-full overflow-hidden", fullscreen && "invisible"); } +/** + * Owns the drag-vs-click filter, so the panel and the standalone story behave identically. + * Fullscreen needs a `relative` ancestor for `agentTakeoverClassName`, supplied by the caller. + * Always mounted as the sole wrapper of `children` across all three modes — the caller must + * never branch its own tree around this component, or a mode switch remounts the chat. + */ +export function FloatingAgentWindow({ + mode, + children, +}: { + mode: DashboardAgentMode; + children: (drag: FloatingDragProps) => React.ReactNode; +}) { + const fullscreen = mode === "fullscreen"; + const docked = mode === "rightPanel"; + const initial = useMemo(() => initialFloatingRect(), []); + const { style, dragHandleProps, resizeHandleProps } = useDraggableResizable({ + initial, + minSize: FLOATING_MIN_SIZE, + viewportPadding: FLOATING_MARGIN, + }); + const [dragging, setDragging] = useState(false); + // onPan can arrive before onPanStart, so the no-drag check runs once, on whichever fires first. + const gestureClassified = useRef(false); + const ignoringGesture = useRef(false); + + const classifyGesture = (event: PointerEvent) => { + if (gestureClassified.current) return; + gestureClassified.current = true; + ignoringGesture.current = !!(event.target as HTMLElement | null)?.closest(NO_DRAG_SELECTOR); + }; + + // Same shape as `dragHandleProps` below empty, so a mode with no drag doesn't change types. + const filteredDragHandleProps: Partial = + fullscreen || docked + ? {} + : { + onPanStart: (event: PointerEvent, info: PanInfo) => { + classifyGesture(event); + if (ignoringGesture.current) return; + setDragging(true); + dragHandleProps.onPanStart?.(event, info); + }, + onPan: (event: PointerEvent, info: PanInfo) => { + classifyGesture(event); + if (ignoringGesture.current) return; + dragHandleProps.onPan?.(event, info); + }, + onPanEnd: (event: PointerEvent, info: PanInfo) => { + gestureClassified.current = false; + ignoringGesture.current = false; + setDragging(false); + dragHandleProps.onPanEnd?.(event, info); + }, + }; + + // Same two-`div` shape in all three modes — only classes/style change — so switching `mode` + // never unmounts `children`; only className/style differ. + return ( + + {/* Clips content to the rounded corners without clipping the resize handles below, + which sit half outside this box's edges. */} + + {/* oxlint-disable-next-line react/refs -- the ref is only read inside event handlers, not during render. */} + {children({ + dragHandleProps: filteredDragHandleProps, + dragHandleClassName: + fullscreen || docked + ? "" + : cn("select-none touch-none", dragging ? "cursor-grabbing" : "cursor-grab"), + })} + + {!fullscreen && + !docked && + RESIZE_EDGES.map((edge) => ( + + ))} + + ); +} + export function AgentPanelColumn({ fullscreen, children, diff --git a/apps/webapp/app/components/dashboard-agent/progress-line.ts b/apps/webapp/app/components/dashboard-agent/progress-line.ts index 0f9c1bcd95e..f853ab94af1 100644 --- a/apps/webapp/app/components/dashboard-agent/progress-line.ts +++ b/apps/webapp/app/components/dashboard-agent/progress-line.ts @@ -106,6 +106,13 @@ export function inFlightToolName(messages: ReadonlyArray): stri return null; } +/** A prose-only turn has no tool part to catch; a `text` part mid-stream has `state: "streaming"`. */ +export function hasUnfinishedTextPart(messages: ReadonlyArray): boolean { + const last = messages[messages.length - 1]; + if (!last || last.role !== "assistant") return false; + return partsOf(last).some((part) => part?.type === "text" && part.state === "streaming"); +} + /** Must stay non-null for the whole in-flight period: null unmounts, and a gap blinks. */ export function liveProgress( messages: ReadonlyArray, diff --git a/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts b/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts new file mode 100644 index 00000000000..8efb1194726 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/resume-wiring.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { chatSessionsOption } from "./DashboardAgentChat"; +import { resolveOpenedChat } from "./opened-chat"; + +const CHAT_ID = "chat_abc123"; + +// A tool call the stream died on: still `input-available`, no result part. +const unfinishedMessage = { + id: "msg_2", + role: "assistant", + parts: [{ type: "tool-run_query", state: "input-available" }], +}; + +describe("the transport's sessions option, built from a real resolveOpenedChat result", () => { + it("marks isStreaming when the reopened chat's transcript still looks mid-turn", () => { + const opened = resolveOpenedChat(CHAT_ID, { + messages: [unfinishedMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + if (opened.kind !== "chat") throw new Error("expected a chat"); + + const sessions = chatSessionsOption(CHAT_ID, opened.session, opened.streaming); + + expect(sessions?.[CHAT_ID]?.isStreaming).toBe(true); + }); + + it("does not mark isStreaming once the transcript has settled", () => { + const settledMessage = { id: "msg_1", role: "user", parts: [{ type: "text", text: "hi" }] }; + const opened = resolveOpenedChat(CHAT_ID, { + messages: [settledMessage], + session: { publicAccessToken: "pat_1", lastEventId: "evt_9" }, + }); + if (opened.kind !== "chat") throw new Error("expected a chat"); + + const sessions = chatSessionsOption(CHAT_ID, opened.session, opened.streaming); + + expect(sessions?.[CHAT_ID]?.isStreaming).toBe(false); + }); + + it("omits the session entirely when there is none to resume", () => { + expect(chatSessionsOption(CHAT_ID, null, true)).toBeUndefined(); + }); +}); + +// Structural: a live SSE resume is impractical in jsdom, so the teardown decision is +// pinned by source instead of driven end to end. +describe("the chat's teardown decision, source-checked", () => { + const chat = readFileSync(new URL("./DashboardAgentChat.tsx", import.meta.url), "utf8"); + const panel = readFileSync(new URL("./DashboardAgentPanel.tsx", import.meta.url), "utf8"); + + it("has exactly one `transport.stopGeneration(` call site, gated by teardownCancelsTurn", () => { + const occurrences = [...chat.matchAll(/transport\.stopGeneration\(/g)]; + expect(occurrences).toHaveLength(1); + expect(chat).toContain("if (!teardownCancelsTurn(reason)) return;"); + }); + + it("passes the opened chat's streaming flag through to the mounted chat", () => { + expect(panel).toContain("setActive({ ...opened, organizationId: organization.id });"); + expect(panel).toContain("streaming={active.streaming}"); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts b/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts index 1adcb6efe58..eafb42b8c16 100644 --- a/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts +++ b/apps/webapp/app/components/dashboard-agent/settled-transcript.test.ts @@ -116,6 +116,27 @@ describe("replacing a stale running step from the re-read", () => { expect(merged.map((message) => message.id)).toEqual([RUNNING_STEP.id, SETTLED.id]); expect(merged[0]).toBe(RUNNING_STEP); }); + + // A prose-only turn: no tool part, just a `text` part the stream never marked done. + const RUNNING_TEXT = { + id: "msg_text", + role: "assistant", + parts: [{ type: "text", text: "Concurrency on the ", state: "streaming" }], + }; + + const FINISHED_TEXT = { + id: "msg_text", + role: "assistant", + parts: [ + { type: "text", text: "Concurrency on the `emails` queue hit its limit.", state: "done" }, + ], + }; + + it("swaps a still-streaming text part for its settled version too", () => { + const merged = mergeSettledMessages([RUNNING_TEXT], [FINISHED_TEXT]); + expect(merged).toEqual([FINISHED_TEXT]); + expect(transcriptLooksUnfinished(merged)).toBe(false); + }); }); describe("reading the transcript endpoint", () => { @@ -171,10 +192,26 @@ describe("deciding whether a settled turn is worth re-reading", () => { parts: [{ type: "tool-get_report", toolCallId: "call_1", state: "input-available" }], }; + // A prose-only reply: no tool part to catch, just a `text` part still streaming. + const DANGLING_TEXT = { + id: "msg_dangling_text", + role: "assistant", + parts: [{ type: "text", text: "Concurrency on the ", state: "streaming" }], + }; + it("re-reads when the stream died mid-tool, not only when a card is open", () => { expect(transcriptLooksUnfinished([DANGLING_TOOL])).toBe(true); }); + it("re-reads when the stream died mid-text, with no tool part at all", () => { + expect(transcriptLooksUnfinished([DANGLING_TEXT])).toBe(true); + }); + + it("leaves a finished text part alone", () => { + const finished = { ...DANGLING_TEXT, parts: [{ type: "text", text: "Done.", state: "done" }] }; + expect(transcriptLooksUnfinished([finished])).toBe(false); + }); + it("re-reads while a card is still open", () => { expect(transcriptLooksUnfinished([OPEN])).toBe(true); }); diff --git a/apps/webapp/app/components/dashboard-agent/settled-transcript.ts b/apps/webapp/app/components/dashboard-agent/settled-transcript.ts index 187d67f5588..61a8ff885a4 100644 --- a/apps/webapp/app/components/dashboard-agent/settled-transcript.ts +++ b/apps/webapp/app/components/dashboard-agent/settled-transcript.ts @@ -1,4 +1,9 @@ -import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./progress-line"; +import { + hasUnfinishedTextPart, + IN_FLIGHT_TOOL_STATES, + inFlightToolName, + liveInvestigation, +} from "./progress-line"; /** * Re-reading the stored transcript once a turn settles. @@ -11,15 +16,16 @@ import { IN_FLIGHT_TOOL_STATES, inFlightToolName, liveInvestigation } from "./pr type Identified = { id: string }; -/** A message whose stream died mid-tool: a `tool-*` part still reads as running. */ +/** A message whose stream died mid-tool or mid-text: a part still reads as running. */ function stillRunning(message: unknown): boolean { const parts = (message as { parts?: ReadonlyArray<{ type?: string; state?: string }> })?.parts; if (!Array.isArray(parts)) return false; return parts.some( (part) => - typeof part?.type === "string" && - part.type.startsWith("tool-") && - IN_FLIGHT_TOOL_STATES.has(part.state ?? "") + (typeof part?.type === "string" && + part.type.startsWith("tool-") && + IN_FLIGHT_TOOL_STATES.has(part.state ?? "")) || + (part?.type === "text" && part.state === "streaming") ); } @@ -65,7 +71,11 @@ export function hasOpenInvestigation(messages: ReadonlyArray): boolean * not the only shape a re-read has to recover from. */ export function transcriptLooksUnfinished(messages: ReadonlyArray): boolean { - return hasOpenInvestigation(messages) || inFlightToolName(messages as never) !== null; + return ( + hasOpenInvestigation(messages) || + inFlightToolName(messages as never) !== null || + hasUnfinishedTextPart(messages as never) + ); } /** diff --git a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx index 976d7f25d55..caf0533d1e6 100644 --- a/apps/webapp/app/components/primitives/AgentDotMatrix.tsx +++ b/apps/webapp/app/components/primitives/AgentDotMatrix.tsx @@ -25,7 +25,14 @@ 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; + +/** Shared so anything else drawing on this grid stays visually identical to the shape library. */ +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/components/primitives/DraggableResizable.dom.test.ts b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts new file mode 100644 index 00000000000..50e1d360f42 --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.dom.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +// Framer-motion can deliver onPan before onPanStart; drives the real handlers to prove +// the hook survives that ordering (draggableResizableMath.test.ts only covers the math). +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 set too (framer always sends both), so a reverted implementation fails 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/app/components/primitives/DraggableResizable.tsx b/apps/webapp/app/components/primitives/DraggableResizable.tsx new file mode 100644 index 00000000000..bdf9c7b2224 --- /dev/null +++ b/apps/webapp/app/components/primitives/DraggableResizable.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { type PanInfo } from "framer-motion"; +import { cn } from "~/utils/cn"; +import { + applyDragDelta, + applyResizeDelta, + clampPosition, + clampRectToViewport, + clampSize, + type Point, + type Rect, + type ResizeEdge, + type Size, + type Viewport, +} 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 from state; `x`/`y` are the top-left corner in viewport coordinates. */ + style: CSSProperties; + dragHandleProps: PanHandlerProps; + resizeHandleProps: (edge: ResizeEdge) => PanHandlerProps; + position: Point; + size: Size; +}; + +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 }; +} + +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 }; + }); + + // 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]); + + // Folds `info.delta` via functional setState, no gesture-start baseline: onPan can + // arrive before onPanStart, which would make a ref-based baseline stale. + const dragHandleProps: PanHandlerProps = { + onPanStart: () => {}, + onPan: (_event, info: PanInfo) => { + setRect((current) => applyDragDelta(current, info.delta, getViewport(), viewportPadding)); + }, + onPanEnd: () => {}, + }; + + const resizeHandleProps = (edge: ResizeEdge): PanHandlerProps => ({ + onPanStart: () => {}, + onPan: (_event, info: PanInfo) => { + setRect((current) => + applyResizeDelta( + edge, + current, + info.delta, + minSize, + maxSize, + getViewport(), + viewportPadding + ) + ); + }, + onPanEnd: () => {}, + }); + + 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/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 0f1a82df3a6..2d178dc62af 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -223,7 +223,11 @@ function PopoverArrowTrigger({ > {children} - + {/* `data-agent-no-drag`: an opt-out marker draggable-window hosts check via closest(). + `contents` keeps this wrapper invisible to layout. */} + + + ); } 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..5c9d5f6177c --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import { + applyDragDelta, + applyResizeDelta, + clamp, + clampPosition, + clampRectToViewport, + clampSize, + resizeRect, + type Rect, +} 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 }; + // 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, 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, 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, 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, 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, undefined, viewport, padding)).toEqual({ + x: 120, + y: 120, + w: 280, + h: 180, + }); + expect(resizeRect("se", start, -20, -20, minSize, undefined, viewport, padding)).toEqual({ + x: 100, + y: 100, + w: 280, + h: 180, + }); + }); + + it("respects min size when shrinking past it", () => { + 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, 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, 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); + }); +}); + +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); + }); +}); + +// onPan can arrive before onPanStart, so these prove the delta-folding approach has no +// baseline to go stale across back-to-back gestures. +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 }); + + // 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); + }); + + 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 new file mode 100644 index 00000000000..dfeecd2ca8e --- /dev/null +++ b/apps/webapp/app/components/primitives/draggableResizableMath.ts @@ -0,0 +1,118 @@ +// 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 }; +} + +// North/west edges move the opposite corner too, so the cap is derived from the fixed far +// edge and growth can't push it past the viewport padding. +export function resizeRect( + edge: ResizeEdge, + start: Rect, + dx: number, + dy: number, + minSize: Size, + maxSize: Size | undefined, + viewport: Viewport, + padding: number +): 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.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.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.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.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); + } + + return { x, y, w, h }; +} + +// Incremental (framer's per-event `delta`), not start-snapshot-based, since onPan can +// arrive before onPanStart and leave a snapshot baseline stale. +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); +} diff --git a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts index 4870b53a6d5..bb99835f1b0 100644 --- a/apps/webapp/app/routes/storybook.agent-ui/manifest.ts +++ b/apps/webapp/app/routes/storybook.agent-ui/manifest.ts @@ -86,10 +86,14 @@ 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: "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.ai-agent/route.tsx b/apps/webapp/app/routes/storybook.ai-agent/route.tsx index c8359ceba34..e14d8333b87 100644 --- a/apps/webapp/app/routes/storybook.ai-agent/route.tsx +++ b/apps/webapp/app/routes/storybook.ai-agent/route.tsx @@ -13,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. @@ -180,6 +183,88 @@ function DotMatrixTab() { ))} + + + + + ); +} + +// 1.5x the 32px candidate icons this replaced. +const EDITOR_SIZE = 48; + +/** 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, + onToggle, +}: { + lit: boolean[]; + size: number; + 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} + {...(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); + }, + })} + /> + ); + })} + + ); +} + +/** 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 toggle = (index: number) => { + setLit((current) => current.map((value, i) => (i === index ? !value : value))); + }; + + const rows = Array.from({ length: MATRIX }, (_, r) => + Array.from({ length: MATRIX }, (_, c) => (lit[r * MATRIX + c] ? "#" : ".")).join("") + ); + + return ( + + + + {rows.join("\n")} + ); } 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..14e1d7e616f --- /dev/null +++ b/apps/webapp/app/routes/storybook.dashboard-agent-floating/route.tsx @@ -0,0 +1,97 @@ +import { useLocation } from "@remix-run/react"; +import { motion } from "framer-motion"; +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"; +import type { DashboardAgentChat } from "~/components/dashboard-agent/DashboardAgentHistory"; +import { + FloatingAgentWindow, + type DashboardAgentMode, +} 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[] = []; + +/** Static content only: this demos the shell (drag, resize, fullscreen), not a live backend. */ +export default function Story() { + const [open, setOpen] = useState(true); + const [mode, setMode] = useState("floating"); + const closeWindow = () => { + setOpen(false); + setMode("floating"); + }; + + // 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: closes the demo on route change. The real chat intentionally persists. + const { pathname } = useLocation(); + const previousPathname = useRef(pathname); + useEffect(() => { + if (previousPathname.current === pathname) return; + previousPathname.current = pathname; + closeWindow(); + }, [pathname]); + + 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. + + + {mounted && !open && ( + + setOpen(true)}> + Open chat + + + )} + {mounted && open && ( + + {({ dragHandleProps, dragHandleClassName }) => ( + + + {}} + onSelectChat={() => {}} + onDeleteChat={() => {}} + mode={mode} + onModeChange={setMode} + onClose={closeWindow} + /> + + + + + + + + + + + )} + + )} + + ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 35f51682938..09e6bcf0639 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -131,6 +131,7 @@ 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" }, 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/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", ], 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: {}
{block.headline}
{block.lifetime}
{block.detail}
+ {rows.join("\n")} +