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 (
+
+ );
+}
+
+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: {}