diff --git a/CLAUDE.md b/CLAUDE.md index 46eacf2b..fd81eaa9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,11 +31,11 @@ dispatch/ ├── scripts/ # e2e-isolated.sh, generate-icon-colors.ts ├── .dispatch/ # repo-level Dispatch config │ ├── config.json # repo-level settings (e.g. Linear integration) -│ ├── job-prompts/ # job prompt definitions │ ├── job-state/ # persistent state files for recurring jobs │ ├── personas/ # persona definitions (*.md) │ └── tools.json # repo-specific MCP tools + lifecycle hooks └── docs/ + └── jobs/ # job prompt definitions (backup copies) ``` - Use `pnpm` (not npm) for all package management. diff --git a/apps/web/package.json b/apps/web/package.json index a6417dfa..38b76bb2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/react": "^16.3.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.7.0", @@ -61,6 +62,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^15.15.0", + "jsdom": "^29.1.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript-eslint": "^8.56.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e272406..3bb8aec0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -28,6 +28,8 @@ import { } from "@/lib/agent-types"; import { type IdeType, sanitizeEnabledIdes } from "@/lib/ide-types"; import { sortAgentsByCreatedAtDesc } from "@/lib/agent-sort"; +import { TipQueueProvider } from "@/components/tips/tip-queue-provider"; +import { TipsVersionInit } from "@/components/tips/tips-version-init"; import { agentRoute } from "@/lib/agent-routes"; import { ReleaseAvailableToast } from "@/components/app/release-available-toast"; import { UpdateAvailableToast } from "@/components/app/update-available-toast"; @@ -240,7 +242,8 @@ export function DashboardLayout(): JSX.Element { }; return ( - <> + + @@ -254,7 +257,7 @@ export function DashboardLayout(): JSX.Element { richColors toastOptions={{ duration: 3000 }} /> - + ); } diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 9e863944..e26e7e5b 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -30,6 +30,8 @@ import { SidebarShell, type NavSection } from "@/components/app/sidebar-shell"; import { StopAgentDialog } from "@/components/app/stop-agent-dialog"; import { QuickPhrasesButton } from "@/components/app/quick-phrases"; import { TerminalPane } from "@/components/app/terminal-pane"; +import { AmbientTipBar } from "@/components/tips/ambient-tip-bar"; +import { TipSpot } from "@/components/tips/tip-spot"; import { type Agent, type AgentVisualState, @@ -567,14 +569,16 @@ export function AgentsView({ ) : null} - + + +
{focusedAgent?.name ? ( @@ -625,6 +629,11 @@ export function AgentsView({ : null } /> + {!isMobile ? ( +
+ +
+ ) : null}
{!isMobile ? ( diff --git a/apps/web/src/components/app/docs-pane.tsx b/apps/web/src/components/app/docs-pane.tsx index 01b68c2c..7b63bdbc 100644 --- a/apps/web/src/components/app/docs-pane.tsx +++ b/apps/web/src/components/app/docs-pane.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { ArrowDownToLine, @@ -160,12 +161,14 @@ export function DocsContent({ onSectionChange: _onSectionChange, title = "Docs", }: DocsContentProps): JSX.Element { + const location = useLocation(); const resolvedInitial = isValidDocsSection(initialSection) ? initialSection : null; const [activeSection, setActiveSectionState] = useState( resolvedInitial ); + const contentRef = useRef(null); useEffect(() => { if (isValidDocsSection(initialSection)) { @@ -173,6 +176,16 @@ export function DocsContent({ } }, [initialSection]); + useEffect(() => { + const hash = location.hash.replace("#", ""); + if (!hash || !contentRef.current) return; + const frame = requestAnimationFrame(() => { + const el = contentRef.current?.querySelector(`#${CSS.escape(hash)}`); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + return () => cancelAnimationFrame(frame); + }, [location.hash, activeSection]); + const active = SECTIONS.find((section) => section.id === activeSection) ?? SECTIONS[0]; @@ -180,7 +193,10 @@ export function DocsContent({
-
+
diff --git a/apps/web/src/components/app/docs-sections/agents.tsx b/apps/web/src/components/app/docs-sections/agents.tsx index 9c517b3f..83d83e81 100644 --- a/apps/web/src/components/app/docs-sections/agents.tsx +++ b/apps/web/src/components/app/docs-sections/agents.tsx @@ -165,7 +165,7 @@ export function AgentsContent() {
-

Quick Phrases

+

Quick Phrases

The Quick Phrases button (speech-bubble icon) in the terminal top rail lets you save reusable text snippets and inject them diff --git a/apps/web/src/components/app/docs-sections/primitives.tsx b/apps/web/src/components/app/docs-sections/primitives.tsx index 8d1076a8..ba98caed 100644 --- a/apps/web/src/components/app/docs-sections/primitives.tsx +++ b/apps/web/src/components/app/docs-sections/primitives.tsx @@ -20,9 +20,17 @@ export function P({ children }: { children: React.ReactNode }) { ); } -export function H3({ children }: { children: React.ReactNode }) { +export function H3({ + id, + children, +}: { + id?: string; + children: React.ReactNode; +}) { return ( -

{children}

+

+ {children} +

); } diff --git a/apps/web/src/components/app/docs-sections/tools.tsx b/apps/web/src/components/app/docs-sections/tools.tsx index b4947be3..73414c6d 100644 --- a/apps/web/src/components/app/docs-sections/tools.tsx +++ b/apps/web/src/components/app/docs-sections/tools.tsx @@ -214,7 +214,7 @@ export function ToolsContent() {
-

Brain (shared memory)

+

Brain (shared memory)

The Brain is a repo-scoped key-value store and event log that lets agents share structured state. Objects are organized into collections, diff --git a/apps/web/src/components/app/notification-settings.tsx b/apps/web/src/components/app/notification-settings.tsx index 95477843..6687425e 100644 --- a/apps/web/src/components/app/notification-settings.tsx +++ b/apps/web/src/components/app/notification-settings.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useAtom } from "jotai"; -import { Play } from "lucide-react"; +import { useAtom, useSetAtom } from "jotai"; +import { Play, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; import { soundCuesEnabledAtom } from "@/lib/store"; +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "@/lib/tips/tips-state"; import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues"; import { getNotificationPermission, @@ -91,6 +97,50 @@ function SoundCuesSection(): JSX.Element { ); } +function TipsSection(): JSX.Element { + const [enabled, setEnabled] = useAtom(tipsEnabledAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + const setLastSeenVersion = useSetAtom(lastSeenVersionAtom); + + return ( +

+

+ Tips & Guidance +

+

+ Contextual tips that highlight features and link to docs. This device + only. +

+
+ + +
+
+ ); +} + export function NotificationSettings(): JSX.Element { // Slack settings const [webhookUrl, setWebhookUrl] = useState(""); @@ -366,6 +416,10 @@ export function NotificationSettings(): JSX.Element {
+
+ +
+ {/* Browser Notifications */}

diff --git a/apps/web/src/components/tips/ambient-tip-bar.tsx b/apps/web/src/components/tips/ambient-tip-bar.tsx new file mode 100644 index 00000000..0d067262 --- /dev/null +++ b/apps/web/src/components/tips/ambient-tip-bar.tsx @@ -0,0 +1,264 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Lightbulb } from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { tips, type Tip } from "@/lib/tips/tips"; +import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; +import { cn } from "@/lib/utils"; + +const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes +const SHOW_CHANCE = 0.4; +const AUTO_HIDE_MS = 30_000; +const ALL_SEEN_MS = 5_000; + +export function AmbientTipBar() { + const navigate = useNavigate(); + const enabled = useAtomValue(tipsEnabledAtom); + const dismissed = useAtomValue(dismissedTipsAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + const setEnabled = useSetAtom(tipsEnabledAtom); + + const [visibleTip, setVisibleTip] = useState(null); + const [allSeenMessage, setAllSeenMessage] = useState(false); + const shownThisSessionRef = useRef(new Set()); + const hoveredRef = useRef(false); + const autoHideTimerRef = useRef>(); + const idleTimerRef = useRef>(); + const allSeenTimerRef = useRef>(); + + const getEligibleTip = useCallback((): Tip | null => { + const eligible = tips.filter( + (t) => + t.surfaces.includes("ambient") && + !dismissed.includes(t.id) && + !shownThisSessionRef.current.has(t.id) + ); + if (eligible.length === 0) return null; + return eligible[Math.floor(Math.random() * eligible.length)]!; + }, [dismissed]); + + const startAutoHide = useCallback(() => { + clearTimeout(autoHideTimerRef.current); + autoHideTimerRef.current = setTimeout(() => { + if (!hoveredRef.current) { + setVisibleTip(null); + } + }, AUTO_HIDE_MS); + }, []); + + const resetIdleTimer = useCallback(() => { + clearTimeout(idleTimerRef.current); + if (!enabled || visibleTip) return; + + idleTimerRef.current = setTimeout(() => { + if (Math.random() > SHOW_CHANCE) { + resetIdleTimer(); + return; + } + const tip = getEligibleTip(); + if (tip) { + shownThisSessionRef.current.add(tip.id); + setVisibleTip(tip); + startAutoHide(); + } + }, IDLE_DELAY_MS); + }, [enabled, visibleTip, getEligibleTip, startAutoHide]); + + const lastResetRef = useRef(0); + + useEffect(() => { + if (!enabled) { + setVisibleTip(null); + return; + } + + const onActivity = () => { + if (visibleTip) return; + const now = Date.now(); + if (now - lastResetRef.current < 10_000) return; + lastResetRef.current = now; + resetIdleTimer(); + }; + + resetIdleTimer(); + window.addEventListener("mousemove", onActivity); + window.addEventListener("keydown", onActivity); + + return () => { + clearTimeout(idleTimerRef.current); + clearTimeout(autoHideTimerRef.current); + window.removeEventListener("mousemove", onActivity); + window.removeEventListener("keydown", onActivity); + }; + }, [enabled, visibleTip, resetIdleTimer]); + + const handleMouseEnter = useCallback(() => { + hoveredRef.current = true; + clearTimeout(autoHideTimerRef.current); + }, []); + + const handleMouseLeave = useCallback(() => { + hoveredRef.current = false; + if (visibleTip) startAutoHide(); + }, [visibleTip, startAutoHide]); + + const handleDismiss = useCallback(() => { + if (visibleTip) { + setDismissed((prev) => + prev.includes(visibleTip.id) ? prev : [...prev, visibleTip.id] + ); + } + setVisibleTip(null); + clearTimeout(autoHideTimerRef.current); + resetIdleTimer(); + }, [visibleTip, setDismissed, resetIdleTimer]); + + const handleDisableAll = useCallback(() => { + setEnabled(false); + setVisibleTip(null); + setAllSeenMessage(false); + clearTimeout(autoHideTimerRef.current); + clearTimeout(allSeenTimerRef.current); + }, [setEnabled]); + + const handleRequestTip = useCallback(() => { + if (visibleTip || allSeenMessage) return; + const eligible = tips.filter( + (t) => t.surfaces.includes("ambient") && !dismissed.includes(t.id) + ); + if (eligible.length === 0) { + setAllSeenMessage(true); + clearTimeout(allSeenTimerRef.current); + allSeenTimerRef.current = setTimeout( + () => setAllSeenMessage(false), + ALL_SEEN_MS + ); + return; + } + const tip = eligible[Math.floor(Math.random() * eligible.length)]!; + shownThisSessionRef.current.add(tip.id); + setVisibleTip(tip); + startAutoHide(); + }, [visibleTip, allSeenMessage, dismissed, startAutoHide]); + + useEffect(() => { + return () => clearTimeout(allSeenTimerRef.current); + }, []); + + if (!enabled) return null; + + const showBar = visibleTip || allSeenMessage; + + return ( +
+ {/* 1: lightbulb — always in the same spot */} + + + {/* 2: tip text — grows, wraps */} + + {visibleTip ? ( + + + {visibleTip.title} + + + {visibleTip.body} + {visibleTip.docsSection ? ( + + ) : null} + + ) : allSeenMessage ? ( + + Nothing left to teach you. You're a Dispatch natural. + + ) : ( + + )} + + + {/* 3: actions — no wrap */} + + {visibleTip ? ( + + + + + ) : allSeenMessage ? ( + + + + ) : null} + +
+ ); +} diff --git a/apps/web/src/components/tips/tip-popover-content.tsx b/apps/web/src/components/tips/tip-popover-content.tsx new file mode 100644 index 00000000..2d24c7e0 --- /dev/null +++ b/apps/web/src/components/tips/tip-popover-content.tsx @@ -0,0 +1,57 @@ +import { Sparkles, X } from "lucide-react"; + +import type { Tip } from "@/lib/tips/tips"; + +type TipPopoverContentProps = { + tip: Tip; + onDismiss: () => void; + onDisableAll: () => void; + onOpenDocs?: (section: string) => void; +}; + +export function TipPopoverContent({ + tip, + onDismiss, + onDisableAll, + onOpenDocs, +}: TipPopoverContentProps) { + return ( +
+
+
+ + + {tip.title} + +
+ +
+

+ {tip.body} +

+
+ {tip.docsSection ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/tips/tip-queue-provider.tsx b/apps/web/src/components/tips/tip-queue-provider.tsx new file mode 100644 index 00000000..8f96ab25 --- /dev/null +++ b/apps/web/src/components/tips/tip-queue-provider.tsx @@ -0,0 +1,63 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; + +type TipQueueContextValue = { + activeTipId: string | null; + requestOpen: (tipId: string) => boolean; + release: (tipId: string) => void; +}; + +const TipQueueContext = createContext({ + activeTipId: null, + requestOpen: () => false, + release: () => {}, +}); + +export function useTipQueue() { + return useContext(TipQueueContext); +} + +export function TipQueueProvider({ children }: { children: React.ReactNode }) { + const [activeTipId, setActiveTipId] = useState(null); + const queueRef = useRef([]); + + const requestOpen = useCallback( + (tipId: string): boolean => { + if (activeTipId === null) { + setActiveTipId(tipId); + return true; + } + if (activeTipId === tipId) return true; + if (!queueRef.current.includes(tipId)) { + queueRef.current.push(tipId); + } + return false; + }, + [activeTipId] + ); + + const release = useCallback((tipId: string) => { + setActiveTipId((current) => { + if (current !== tipId) return current; + const next = queueRef.current.shift() ?? null; + return next; + }); + }, []); + + const value = useMemo( + () => ({ activeTipId, requestOpen, release }), + [activeTipId, requestOpen, release] + ); + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx new file mode 100644 index 00000000..05f68f87 --- /dev/null +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -0,0 +1,207 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { useNavigate } from "react-router-dom"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTip } from "@/lib/tips/use-tip"; + +import { TipPopoverContent } from "./tip-popover-content"; +import { useTipQueue } from "./tip-queue-provider"; + +/** Check if the element is actually reachable (not covered by an overlay). */ +function isTriggerReachable(el: HTMLElement): boolean { + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + const topEl = document.elementFromPoint(cx, cy); + if (!topEl) return false; + return el.contains(topEl) || el === topEl; +} + +function TipArrow({ side, offset }: { side: string; offset?: number }) { + const isBottom = side === "bottom" || side === "right"; + return ( + + {isBottom ? ( + <> + + + + ) : ( + <> + + + + )} + + ); +} + +type TipSpotProps = { + tipId: string; + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + sideOffset?: number; + children: React.ReactNode; +}; + +export function TipSpot({ + tipId, + side = "right", + align = "center", + sideOffset = 8, + children, +}: TipSpotProps) { + const navigate = useNavigate(); + const { tip, shouldShowInline, dismiss, disableAll } = useTip(tipId); + const { requestOpen, release } = useTipQueue(); + const [open, setOpen] = useState(false); + const eligibleRef = useRef(false); + const triggerRef = useRef(null); + const contentRef = useRef(null); + const [arrowOffset, setArrowOffset] = useState(); + + // Latch eligibility so the popover survives lastSeenVersion updates + if (shouldShowInline) { + eligibleRef.current = true; + } + + useEffect(() => { + if (!shouldShowInline || open) return; + const timer = setTimeout(() => { + if (requestOpen(tipId)) setOpen(true); + }, 500); + return () => clearTimeout(timer); + }, [shouldShowInline, open, tipId, requestOpen]); + + // Post-commit reachability: checked in useLayoutEffect so we read + // the actual committed DOM (not the stale previous-commit snapshot + // that a render-time read would see). + const [reachable, setReachable] = useState(false); + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs every commit; external DOM state (overlays) isn't in deps + useLayoutEffect(() => { + const next = + open && triggerRef.current + ? isTriggerReachable(triggerRef.current) + : false; + if (next !== reachable) setReachable(next); + }); + + // Compute arrow offset so it points at the trigger, not the popover center + useEffect(() => { + if (!open) { + setArrowOffset(undefined); + return; + } + const frame = requestAnimationFrame(() => { + const trigger = triggerRef.current; + const content = contentRef.current; + if (!trigger || !content) return; + const triggerRect = trigger.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + const triggerCenter = triggerRect.left + triggerRect.width / 2; + setArrowOffset(triggerCenter - contentRect.left); + }); + return () => cancelAnimationFrame(frame); + }, [open]); + + const handleDismiss = useCallback(() => { + setOpen(false); + dismiss(); + release(tipId); + }, [dismiss, release, tipId]); + + const handleDisableAll = useCallback(() => { + setOpen(false); + disableAll(); + release(tipId); + }, [disableAll, release, tipId]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && reachable) { + handleDismiss(); + } + }, + [handleDismiss, reachable] + ); + + useEffect(() => { + if (!open || !reachable) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") handleDismiss(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, reachable, handleDismiss]); + + const handleOpenDocs = useCallback( + (section: string) => { + handleDismiss(); + navigate(`/settings/help/${section}`); + }, + [handleDismiss, navigate] + ); + + if (!tip || !eligibleRef.current) { + return <>{children}; + } + + return ( + + + + {children} + + + e.preventDefault()} + > + + + + + ); +} diff --git a/apps/web/src/components/tips/tips-version-init.tsx b/apps/web/src/components/tips/tips-version-init.tsx new file mode 100644 index 00000000..e79e502c --- /dev/null +++ b/apps/web/src/components/tips/tips-version-init.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from "react"; +import { useAtom } from "jotai"; + +import { BUILD_VERSION } from "@/lib/version"; +import { lastSeenVersionAtom } from "@/lib/tips/tips-state"; + +export function TipsVersionInit() { + const [lastSeen, setLastSeen] = useAtom(lastSeenVersionAtom); + const didInit = useRef(false); + + useEffect(() => { + if (didInit.current) return; + didInit.current = true; + if (lastSeen !== BUILD_VERSION) { + const timer = setTimeout(() => setLastSeen(BUILD_VERSION), 2000); + return () => clearTimeout(timer); + } + }, [lastSeen, setLastSeen]); + + return null; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ab65c6b5..5cb88bf7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1152,3 +1152,12 @@ html[data-hidden] .animate-sidebar-nav-pulse { [data-sonner-toaster] [data-button]:hover { background: hsl(var(--muted)) !important; } + +/* Tip popover — remove inset shadow and backdrop-blur so the SVG arrow + fill matches the popover background exactly. */ +.tip-popover { + box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + animation-duration: 300ms !important; +} diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 22466dc9..33e00265 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -3,7 +3,7 @@ import { atomFamily } from "jotai/utils"; import { type IdeType } from "./ide-types"; -function atomWithLocalStorage(key: string, initialValue: T) { +export function atomWithLocalStorage(key: string, initialValue: T) { const baseAtom = atom( (() => { if (typeof window === "undefined") return initialValue; diff --git a/apps/web/src/lib/tips/__tests__/tips-state.test.ts b/apps/web/src/lib/tips/__tests__/tips-state.test.ts new file mode 100644 index 00000000..7a3541f7 --- /dev/null +++ b/apps/web/src/lib/tips/__tests__/tips-state.test.ts @@ -0,0 +1,45 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "../tips-state"; + +// @vitest-environment jsdom + +describe("tips state atoms", () => { + let store: ReturnType; + + beforeEach(() => { + window.localStorage.clear(); + store = createStore(); + }); + + it("tipsEnabledAtom defaults to true", () => { + expect(store.get(tipsEnabledAtom)).toBe(true); + }); + + it("dismissedTipsAtom defaults to empty array", () => { + expect(store.get(dismissedTipsAtom)).toEqual([]); + }); + + it("lastSeenVersionAtom defaults to null", () => { + expect(store.get(lastSeenVersionAtom)).toBeNull(); + }); + + it("tipsEnabledAtom persists to localStorage", () => { + store.set(tipsEnabledAtom, false); + expect(JSON.parse(localStorage.getItem("dispatch:tipsEnabled")!)).toBe( + false + ); + }); + + it("dismissedTipsAtom persists to localStorage", () => { + store.set(dismissedTipsAtom, ["quick-phrases", "personas"]); + expect(JSON.parse(localStorage.getItem("dispatch:dismissedTips")!)).toEqual( + ["quick-phrases", "personas"] + ); + }); +}); diff --git a/apps/web/src/lib/tips/__tests__/tips.test.ts b/apps/web/src/lib/tips/__tests__/tips.test.ts new file mode 100644 index 00000000..2877eb29 --- /dev/null +++ b/apps/web/src/lib/tips/__tests__/tips.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { tips } from "../tips"; + +describe("tips registry", () => { + it("exports a non-empty array of tips", () => { + expect(tips.length).toBeGreaterThan(0); + }); + + it("has unique IDs", () => { + const ids = tips.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every tip has required fields", () => { + for (const tip of tips) { + expect(tip.id).toBeTruthy(); + expect(tip.title).toBeTruthy(); + expect(tip.body).toBeTruthy(); + expect(tip.since).toMatch(/^\d+\.\d+\.\d+$/); + expect(tip.surfaces.length).toBeGreaterThan(0); + for (const s of tip.surfaces) { + expect(["inline", "ambient"]).toContain(s); + } + } + }); +}); diff --git a/apps/web/src/lib/tips/__tests__/use-tip.test.tsx b/apps/web/src/lib/tips/__tests__/use-tip.test.tsx new file mode 100644 index 00000000..7605cef6 --- /dev/null +++ b/apps/web/src/lib/tips/__tests__/use-tip.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { createStore, Provider } from "jotai"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "../tips-state"; +import { useTip } from "../use-tip"; + +vi.mock("@/lib/version", () => ({ BUILD_VERSION: "0.23.0" })); + +function renderUseTip(id: string, store: ReturnType) { + return renderHook(() => useTip(id), { + wrapper: ({ children }) => {children}, + }); +} + +describe("useTip", () => { + let store: ReturnType; + + beforeEach(() => { + window.localStorage.clear(); + store = createStore(); + }); + + it("returns null tip for unknown ID", () => { + const { result } = renderUseTip("nonexistent", store); + expect(result.current.tip).toBeNull(); + expect(result.current.shouldShowInline).toBe(false); + expect(result.current.shouldShowAmbient).toBe(false); + }); + + it("shouldShowInline is true when tip version is newer than lastSeenVersion", () => { + store.set(lastSeenVersionAtom, "0.21.0"); + const { result } = renderUseTip("personas", store); // since: "0.22.0" + expect(result.current.shouldShowInline).toBe(true); + }); + + it("shouldShowInline is false when tip version is older than lastSeenVersion", () => { + store.set(lastSeenVersionAtom, "0.23.0"); + const { result } = renderUseTip("personas", store); // since: "0.22.0" + expect(result.current.shouldShowInline).toBe(false); + }); + + it("shouldShowInline is false when lastSeenVersion is null (first-time user)", () => { + const { result } = renderUseTip("personas", store); + expect(result.current.shouldShowInline).toBe(false); + }); + + it("shouldShowAmbient is true for undismissed tips regardless of version", () => { + store.set(lastSeenVersionAtom, "0.23.0"); + const { result } = renderUseTip("personas", store); // since: "0.22.0" + expect(result.current.shouldShowAmbient).toBe(true); + }); + + it("shouldShowAmbient is false when tips are disabled", () => { + store.set(tipsEnabledAtom, false); + const { result } = renderUseTip("personas", store); + expect(result.current.shouldShowAmbient).toBe(false); + }); + + it("dismiss marks the tip as dismissed", () => { + store.set(lastSeenVersionAtom, "0.21.0"); + const { result } = renderUseTip("personas", store); + expect(result.current.shouldShowAmbient).toBe(true); + + act(() => result.current.dismiss()); + + expect(result.current.shouldShowAmbient).toBe(false); + expect(result.current.shouldShowInline).toBe(false); + expect(store.get(dismissedTipsAtom)).toContain("personas"); + }); + + it("disableAll sets tipsEnabled to false", () => { + const { result } = renderUseTip("personas", store); + act(() => result.current.disableAll()); + expect(store.get(tipsEnabledAtom)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/tips/__tests__/version-compare.test.ts b/apps/web/src/lib/tips/__tests__/version-compare.test.ts new file mode 100644 index 00000000..36fdf1fa --- /dev/null +++ b/apps/web/src/lib/tips/__tests__/version-compare.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { isVersionNewer } from "../version-compare"; + +describe("isVersionNewer", () => { + it("returns true when version is newer (patch)", () => { + expect(isVersionNewer("0.23.1", "0.23.0")).toBe(true); + }); + + it("returns true when version is newer (minor)", () => { + expect(isVersionNewer("0.24.0", "0.23.5")).toBe(true); + }); + + it("returns true when version is newer (major)", () => { + expect(isVersionNewer("1.0.0", "0.99.99")).toBe(true); + }); + + it("returns false when versions are equal", () => { + expect(isVersionNewer("0.23.0", "0.23.0")).toBe(false); + }); + + it("returns false when version is older", () => { + expect(isVersionNewer("0.22.0", "0.23.0")).toBe(false); + }); + + it("handles versions with v prefix", () => { + expect(isVersionNewer("v0.24.0", "v0.23.0")).toBe(true); + }); + + it("returns false for equal versions with v prefix", () => { + expect(isVersionNewer("v0.23.0", "0.23.0")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/tips/tips-state.ts b/apps/web/src/lib/tips/tips-state.ts new file mode 100644 index 00000000..6450b263 --- /dev/null +++ b/apps/web/src/lib/tips/tips-state.ts @@ -0,0 +1,16 @@ +import { atomWithLocalStorage } from "../store"; + +export const tipsEnabledAtom = atomWithLocalStorage( + "dispatch:tipsEnabled", + true +); + +export const dismissedTipsAtom = atomWithLocalStorage( + "dispatch:dismissedTips", + [] +); + +export const lastSeenVersionAtom = atomWithLocalStorage( + "dispatch:lastSeenVersion", + null +); diff --git a/apps/web/src/lib/tips/tips.ts b/apps/web/src/lib/tips/tips.ts new file mode 100644 index 00000000..d2fbe1c7 --- /dev/null +++ b/apps/web/src/lib/tips/tips.ts @@ -0,0 +1,89 @@ +export type Surface = "inline" | "ambient"; + +export type Tip = { + id: string; + title: string; + body: string; + docsSection?: string; + since: string; + surfaces: Surface[]; +}; + +export const tips: Tip[] = [ + { + id: "quick-phrases", + title: "Quick Phrases", + body: "Inject saved phrases into your terminal session with one click. Create reusable snippets for common commands.", + docsSection: "agents#quick-phrases", + since: "0.23.0", + surfaces: ["inline", "ambient"], + }, + { + id: "personas", + title: "Personas", + body: "Launch specialized review agents with structured feedback. Define reusable roles for security, UX, or code review.", + docsSection: "personas", + since: "0.22.0", + surfaces: ["inline", "ambient"], + }, + { + id: "brain", + title: "Brain", + body: "Repo-scoped shared memory that persists across agent sessions. Store objects, lists, and event logs your agents can manage.", + docsSection: "tools#brain", + since: "0.20.0", + surfaces: ["inline", "ambient"], + }, + { + id: "automations", + title: "Automations", + body: "Schedule recurring jobs like PR triage, dependency checks, or custom workflows on a cron schedule.", + docsSection: "automations", + since: "0.18.0", + surfaces: ["inline", "ambient"], + }, + { + id: "media-sidebar", + title: "Media Sidebar", + body: "View agent pins, screenshots, and shared media in a collapsible sidebar. Pin it to keep it visible while you work.", + docsSection: "media", + since: "0.19.0", + surfaces: ["inline", "ambient"], + }, + { + id: "keyboard-shortcuts", + title: "Keyboard Shortcuts", + body: "Press ⌘K to open the command palette. Navigate agents, toggle sidebars, and control the terminal without touching the mouse.", + docsSection: "shortcuts", + since: "0.24.0", + surfaces: ["ambient"], + }, + { + id: "worktrees", + title: "Worktrees", + body: "Each agent gets its own git worktree by default — isolated branches, no conflicts. Run multiple agents in parallel on the same repo.", + docsSection: "worktrees", + since: "0.24.0", + surfaces: ["ambient"], + }, + { + id: "personalities", + title: "Personalities", + body: "Customize how agents communicate. Add a personality to shape tone, preferences, or standing instructions across all new agents.", + docsSection: "personalities", + since: "0.24.0", + surfaces: ["ambient"], + }, + { + id: "notifications", + title: "Notifications", + body: "Get notified when agents finish, need input, or get stuck. Set up Slack, browser, or sound alerts in Settings.", + docsSection: "notifications", + since: "0.24.0", + surfaces: ["ambient"], + }, +]; + +export function getTipById(id: string): Tip | undefined { + return tips.find((t) => t.id === id); +} diff --git a/apps/web/src/lib/tips/use-tip.ts b/apps/web/src/lib/tips/use-tip.ts new file mode 100644 index 00000000..56ef14e7 --- /dev/null +++ b/apps/web/src/lib/tips/use-tip.ts @@ -0,0 +1,44 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useMemo } from "react"; + +import { BUILD_VERSION } from "@/lib/version"; + +import { getTipById } from "./tips"; +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "./tips-state"; +import { isVersionNewer } from "./version-compare"; + +export function useTip(id: string) { + const tip = useMemo(() => getTipById(id) ?? null, [id]); + const enabled = useAtomValue(tipsEnabledAtom); + const [dismissed, setDismissed] = useAtom(dismissedTipsAtom); + const setEnabled = useSetAtom(tipsEnabledAtom); + const lastSeenVersion = useAtomValue(lastSeenVersionAtom); + + const isDismissed = dismissed.includes(id); + + const shouldShowInline = + tip !== null && + enabled && + !isDismissed && + tip.surfaces.includes("inline") && + lastSeenVersion !== null && + isVersionNewer(tip.since, lastSeenVersion) && + !isVersionNewer(tip.since, BUILD_VERSION); + + const shouldShowAmbient = + tip !== null && enabled && !isDismissed && tip.surfaces.includes("ambient"); + + const dismiss = useCallback(() => { + setDismissed((prev) => (prev.includes(id) ? prev : [...prev, id])); + }, [id, setDismissed]); + + const disableAll = useCallback(() => { + setEnabled(false); + }, [setEnabled]); + + return { tip, shouldShowInline, shouldShowAmbient, dismiss, disableAll }; +} diff --git a/apps/web/src/lib/tips/version-compare.ts b/apps/web/src/lib/tips/version-compare.ts new file mode 100644 index 00000000..f1fabf79 --- /dev/null +++ b/apps/web/src/lib/tips/version-compare.ts @@ -0,0 +1,13 @@ +function parseVersion(v: string): [number, number, number] { + const cleaned = v.startsWith("v") ? v.slice(1) : v; + const parts = cleaned.split(".").map(Number); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +export function isVersionNewer(a: string, b: string): boolean { + const [aMajor, aMinor, aPatch] = parseVersion(a); + const [bMajor, bMinor, bPatch] = parseVersion(b); + if (aMajor !== bMajor) return aMajor > bMajor; + if (aMinor !== bMinor) return aMinor > bMinor; + return aPatch > bPatch; +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 09ddc4e4..710acf9b 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,7 +1,12 @@ import { defineConfig } from "vitest/config"; import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; export default defineConfig({ + plugins: [react()], + define: { + __DISPATCH_VERSION__: JSON.stringify("0.0.0-test"), + }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), diff --git a/.dispatch/job-prompts/componentizer.md b/docs/jobs/componentizer.md similarity index 100% rename from .dispatch/job-prompts/componentizer.md rename to docs/jobs/componentizer.md diff --git a/.dispatch/job-prompts/docs-audit.md b/docs/jobs/docs-audit.md similarity index 87% rename from .dispatch/job-prompts/docs-audit.md rename to docs/jobs/docs-audit.md index 2ccb394a..d7b7cfba 100644 --- a/.dispatch/job-prompts/docs-audit.md +++ b/docs/jobs/docs-audit.md @@ -55,6 +55,20 @@ For each section in scope, verify: When the app's behavior has changed, update the JSX content in `docs-pane.tsx`. Keep the copy tight — match the existing tone. +## Phase 2b: Check ambient tips for gaps + +Dispatch has a guided tips system that surfaces feature discovery hints in the UI. Tips are defined in `apps/web/src/lib/tips/tips.ts`. Each tip has an `id`, `title`, `body`, optional `docsSection` link, `since` version, and `surfaces` array. + +When your Phase 1 diff or deep-dive area touches a feature that has no corresponding ambient tip, consider whether adding one would genuinely help an end user discover or understand that feature. Not every feature needs a tip — only add one if: + +- The feature is non-obvious or easy to miss (e.g. a keyboard shortcut, a settings toggle, a capability that isn't surfaced in the main UI chrome). +- A short sentence or two would meaningfully help someone who doesn't know the feature exists. +- There isn't already a tip that covers the same ground. + +Do **not** add tips for internal implementation details, developer-facing APIs, or features that are self-evident from the UI. The bar is "would an end user benefit from being told about this?" — if the answer is marginal, skip it. + +When you do add a tip, follow the existing format in `tips.ts`. Set `since` to the current release version (check `package.json`). Use `surfaces: ["ambient"]` unless you're also wiring up an inline `TipSpot` placement (which is a separate code change). Keep the `body` concise — it renders in a small footer bar. Link `docsSection` to the matching in-app docs section if one exists. + ## Phase 3: Audit secondary docs (only as needed) Only touch these if the Phase 1 diff points to them or they're explicitly in `next_focus`: diff --git a/.dispatch/job-prompts/persona-review.md b/docs/jobs/persona-review.md similarity index 100% rename from .dispatch/job-prompts/persona-review.md rename to docs/jobs/persona-review.md diff --git a/.dispatch/job-prompts/tech-debt.md b/docs/jobs/tech-debt.md similarity index 100% rename from .dispatch/job-prompts/tech-debt.md rename to docs/jobs/tech-debt.md diff --git a/.dispatch/job-prompts/test-enforcer.md b/docs/jobs/test-enforcer.md similarity index 100% rename from .dispatch/job-prompts/test-enforcer.md rename to docs/jobs/test-enforcer.md diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bf4a9a8..1ce058dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,13 +88,13 @@ importers: version: 8.18.1 "@vitest/coverage-v8": specifier: 4.1.2 - version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) vite: specifier: ^6.0.0 version: 6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.18 - version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) apps/web: dependencies: @@ -207,6 +207,9 @@ importers: "@tailwindcss/typography": specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) + "@testing-library/react": + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@types/react": specifier: ^18.3.12 version: 18.3.28 @@ -218,7 +221,7 @@ importers: version: 4.7.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) "@vitest/coverage-v8": specifier: 2.1.9 - version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(terser@5.46.1)) + version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) @@ -237,6 +240,9 @@ importers: globals: specifier: ^15.15.0 version: 15.15.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 postcss: specifier: ^8.4.49 version: 8.5.8 @@ -254,7 +260,7 @@ importers: version: 1.2.0(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(terser@5.46.1) + version: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) packages: "@alloc/quick-lru@5.2.0": @@ -3294,6 +3300,37 @@ packages: peerDependencies: react: ^18 || ^19 + "@testing-library/dom@10.4.1": + resolution: + { + integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==, + } + engines: { node: ">=18" } + + "@testing-library/react@16.3.2": + resolution: + { + integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==, + } + engines: { node: ">=18" } + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + + "@types/aria-query@5.0.4": + resolution: + { + integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, + } + "@types/babel__core@7.20.5": resolution: { @@ -3994,6 +4031,13 @@ packages: } engines: { node: ">=8" } + ansi-styles@5.2.0: + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: ">=10" } + ansi-styles@6.2.3: resolution: { @@ -4033,6 +4077,12 @@ packages: } engines: { node: ">=10" } + aria-query@5.3.0: + resolution: + { + integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==, + } + array-buffer-byte-length@1.0.2: resolution: { @@ -5061,6 +5111,12 @@ packages: } engines: { node: ">=0.10.0" } + dom-accessibility-api@0.5.16: + resolution: + { + integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==, + } + dompurify@3.4.5: resolution: { @@ -5144,12 +5200,12 @@ packages: integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, } - entities@6.0.1: + entities@8.0.0: resolution: { - integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, + integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==, } - engines: { node: ">=0.12" } + engines: { node: ">=20.19.0" } environment@1.1.0: resolution: @@ -6457,10 +6513,10 @@ packages: } hasBin: true - jsdom@29.0.2: + jsdom@29.1.1: resolution: { - integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==, + integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==, } engines: { node: ^20.19.0 || ^22.13.0 || >=24.0.0 } peerDependencies: @@ -6696,6 +6752,13 @@ packages: } engines: { node: 20 || >=22 } + lru-cache@11.5.1: + resolution: + { + integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==, + } + engines: { node: 20 || >=22 } + lru-cache@5.1.1: resolution: { @@ -6710,6 +6773,13 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lz-string@1.5.0: + resolution: + { + integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, + } + hasBin: true + magic-string@0.25.9: resolution: { @@ -7344,10 +7414,10 @@ packages: integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==, } - parse5@8.0.0: + parse5@8.0.1: resolution: { - integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==, + integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==, } parseurl@1.3.3: @@ -7707,6 +7777,13 @@ packages: } engines: { node: ^14.13.1 || >=16.0.0 } + pretty-format@27.5.1: + resolution: + { + integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==, + } + engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } + process-warning@4.0.1: resolution: { @@ -7807,6 +7884,12 @@ packages: integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, } + react-is@17.0.2: + resolution: + { + integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==, + } + react-markdown@10.1.0: resolution: { @@ -9657,7 +9740,6 @@ snapshots: "@csstools/css-color-parser": 4.1.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 - optional: true "@asamuzakjp/dom-selector@7.1.1": dependencies: @@ -9666,13 +9748,10 @@ snapshots: bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - optional: true - "@asamuzakjp/generational-cache@1.0.1": - optional: true + "@asamuzakjp/generational-cache@1.0.1": {} - "@asamuzakjp/nwsapi@2.3.9": - optional: true + "@asamuzakjp/nwsapi@2.3.9": {} "@babel/code-frame@7.29.0": dependencies: @@ -10351,18 +10430,15 @@ snapshots: "@bramus/specificity@2.4.2": dependencies: css-tree: 3.2.1 - optional: true "@chevrotain/types@11.1.2": {} - "@csstools/color-helpers@6.0.2": - optional: true + "@csstools/color-helpers@6.0.2": {} "@csstools/css-calc@3.2.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 - optional: true "@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": dependencies: @@ -10370,20 +10446,16 @@ snapshots: "@csstools/css-calc": 3.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 - optional: true "@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": dependencies: "@csstools/css-tokenizer": 4.0.0 - optional: true "@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)": optionalDependencies: css-tree: 3.2.1 - optional: true - "@csstools/css-tokenizer@4.0.0": - optional: true + "@csstools/css-tokenizer@4.0.0": {} "@date-fns/tz@1.4.1": {} @@ -10663,8 +10735,7 @@ snapshots: "@eslint/core": 0.17.0 levn: 0.4.1 - "@exodus/bytes@1.15.0": - optional: true + "@exodus/bytes@1.15.0": {} "@fastify/accept-negotiator@2.0.1": {} @@ -11526,6 +11597,29 @@ snapshots: "@tanstack/query-core": 5.95.2 react: 18.3.1 + "@testing-library/dom@10.4.1": + dependencies: + "@babel/code-frame": 7.29.0 + "@babel/runtime": 7.29.2 + "@types/aria-query": 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + "@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@babel/runtime": 7.29.2 + "@testing-library/dom": 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + "@types/react": 18.3.28 + "@types/react-dom": 18.3.7(@types/react@18.3.28) + + "@types/aria-query@5.0.4": {} + "@types/babel__core@7.20.5": dependencies: "@babel/parser": 7.29.2 @@ -11852,7 +11946,7 @@ snapshots: transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(terser@5.46.1))": + "@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1))": dependencies: "@ampproject/remapping": 2.3.0 "@bcoe/v8-coverage": 0.2.3 @@ -11866,11 +11960,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(terser@5.46.1) + vitest: 2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1) transitivePeerDependencies: - supports-color - "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))": + "@vitest/coverage-v8@4.1.2(vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))": dependencies: "@bcoe/v8-coverage": 1.0.2 "@vitest/utils": 4.1.2 @@ -11882,7 +11976,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) "@vitest/expect@2.1.9": dependencies: @@ -12018,6 +12112,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} @@ -12035,6 +12131,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -12163,7 +12263,6 @@ snapshots: bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - optional: true binary-extensions@2.3.0: {} @@ -12380,7 +12479,6 @@ snapshots: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 - optional: true cssesc@3.0.0: {} @@ -12576,7 +12674,6 @@ snapshots: whatwg-url: 16.0.1 transitivePeerDependencies: - "@noble/hashes" - optional: true data-view-buffer@1.0.2: dependencies: @@ -12608,8 +12705,7 @@ snapshots: decimal.js-light@2.5.1: {} - decimal.js@10.6.0: - optional: true + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: dependencies: @@ -12657,6 +12753,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dompurify@3.4.5: optionalDependencies: "@types/trusted-types": 2.0.7 @@ -12698,8 +12796,7 @@ snapshots: dependencies: once: 1.4.0 - entities@6.0.1: - optional: true + entities@8.0.0: {} environment@1.1.0: {} @@ -13360,7 +13457,6 @@ snapshots: "@exodus/bytes": 1.15.0 transitivePeerDependencies: - "@noble/hashes" - optional: true html-escaper@2.0.2: {} @@ -13519,8 +13615,7 @@ snapshots: is-plain-obj@4.1.0: {} - is-potential-custom-element-name@1.0.1: - optional: true + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -13638,7 +13733,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.0.2: + jsdom@29.1.1: dependencies: "@asamuzakjp/css-color": 5.1.11 "@asamuzakjp/dom-selector": 7.1.1 @@ -13650,8 +13745,8 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.7 - parse5: 8.0.0 + lru-cache: 11.5.1 + parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 @@ -13663,7 +13758,6 @@ snapshots: xml-name-validator: 5.0.0 transitivePeerDependencies: - "@noble/hashes" - optional: true jsesc@3.1.0: {} @@ -13781,6 +13875,8 @@ snapshots: lru-cache@11.2.7: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -13789,6 +13885,8 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 @@ -13972,8 +14070,7 @@ snapshots: dependencies: "@types/mdast": 4.0.4 - mdn-data@2.27.1: - optional: true + mdn-data@2.27.1: {} media-typer@1.1.0: {} @@ -14363,10 +14460,9 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse5@8.0.0: + parse5@8.0.1: dependencies: - entities: 6.0.1 - optional: true + entities: 8.0.0 parseurl@1.3.3: {} @@ -14542,6 +14638,12 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-warning@4.0.1: {} process-warning@5.0.0: {} @@ -14598,6 +14700,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@18.3.28)(react@18.3.1): dependencies: "@types/hast": 3.0.4 @@ -14923,7 +15027,6 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 - optional: true scheduler@0.23.2: dependencies: @@ -15238,8 +15341,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: - optional: true + symbol-tree@3.2.4: {} tailwind-merge@2.6.1: {} @@ -15326,13 +15428,11 @@ snapshots: tinyspy@3.0.2: {} - tldts-core@7.0.28: - optional: true + tldts-core@7.0.28: {} tldts@7.0.28: dependencies: tldts-core: 7.0.28 - optional: true to-regex-range@5.0.1: dependencies: @@ -15345,7 +15445,6 @@ snapshots: tough-cookie@6.0.1: dependencies: tldts: 7.0.28 - optional: true tr46@1.0.1: dependencies: @@ -15354,7 +15453,6 @@ snapshots: tr46@6.0.0: dependencies: punycode: 2.3.1 - optional: true trim-lines@3.0.1: {} @@ -15448,8 +15546,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.25.0: - optional: true + undici@7.25.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -15622,7 +15719,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(terser@5.46.1): + vitest@2.1.9(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(terser@5.46.1): dependencies: "@vitest/expect": 2.1.9 "@vitest/mocker": 2.1.9(vite@5.4.21(@types/node@24.12.0)(terser@5.46.1)) @@ -15647,7 +15744,7 @@ snapshots: optionalDependencies: "@types/node": 24.12.0 happy-dom: 18.0.1 - jsdom: 29.0.2 + jsdom: 29.1.1 transitivePeerDependencies: - less - lightningcss @@ -15659,7 +15756,7 @@ snapshots: - supports-color - terser - vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.0.2)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.2(@types/node@24.12.0)(happy-dom@18.0.1)(jsdom@29.1.1)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: "@vitest/expect": 4.1.2 "@vitest/mocker": 4.1.2(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.7)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -15684,25 +15781,22 @@ snapshots: optionalDependencies: "@types/node": 24.12.0 happy-dom: 18.0.1 - jsdom: 29.0.2 + jsdom: 29.1.1 transitivePeerDependencies: - msw w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - optional: true webidl-conversions@4.0.2: {} - webidl-conversions@8.0.1: - optional: true + webidl-conversions@8.0.1: {} whatwg-mimetype@3.0.0: optional: true - whatwg-mimetype@5.0.0: - optional: true + whatwg-mimetype@5.0.0: {} whatwg-url@16.0.1: dependencies: @@ -15711,7 +15805,6 @@ snapshots: webidl-conversions: 8.0.1 transitivePeerDependencies: - "@noble/hashes" - optional: true whatwg-url@7.1.0: dependencies: @@ -15906,11 +15999,9 @@ snapshots: ws@8.20.0: {} - xml-name-validator@5.0.0: - optional: true + xml-name-validator@5.0.0: {} - xmlchars@2.2.0: - optional: true + xmlchars@2.2.0: {} xtend@4.0.2: {}