From ac7b6dd69e7e0e6207cddf1c6ae6de56e923555b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:28:29 -0600 Subject: [PATCH 01/28] Add design spec for guided tips feature discovery system Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-06-11-guided-tips-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-11-guided-tips-design.md diff --git a/docs/superpowers/specs/2026-06-11-guided-tips-design.md b/docs/superpowers/specs/2026-06-11-guided-tips-design.md new file mode 100644 index 00000000..72c30e51 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-guided-tips-design.md @@ -0,0 +1,182 @@ +# Guided Tips — Feature Discovery System + +A lightweight feature discovery system for Dispatch with two surfaces: inline popovers anchored to new features after an upgrade, and an ambient tip bar in the bottom buffer area that shows tips during idle periods. + +## Audience + +Power users who self-host Dispatch. The system should be respectful of attention, easy to dismiss, and not feel patronizing. + +## Architecture + +### Unified Tip Registry + +A single `tips.ts` file holds all tip definitions as a typed array. Each tip declares: + +```ts +type Tip = { + id: string; // unique, stable identifier (e.g., "quick-phrases") + title: string; // short heading (e.g., "Quick Phrases") + body: string; // 1-2 sentence description + docsPath?: string; // path to in-app docs (renders "Learn more" link) + since: string; // semver version this feature shipped in (e.g., "0.23.0") + surfaces: Surface[]; // which surfaces this tip is eligible for +}; + +type Surface = "inline" | "ambient"; +``` + +- `surfaces` controls where a tip can appear. Most tips are `["inline", "ambient"]`. Tips without a specific UI anchor (general workflow tips) use `["ambient"]` only. +- `since` enables version-gating for inline tips: only show if the user upgraded past this version. + +Adding a new tip is a single entry in this registry. + +### State Management + +Three Jotai atoms persisted to localStorage: + +**`tipsEnabledAtom`** — `atomWithLocalStorage("dispatch:tipsEnabled", true)` +Master toggle. When false, `TipSpot` renders children transparently, ambient bar shows nothing. Controlled from both surfaces' "Don't show tips" links and from Settings. + +**`dismissedTipsAtom`** — `atomWithLocalStorage("dispatch:dismissedTips", [])` +Array of dismissed tip IDs. Shared across both surfaces — dismissing a tip from either surface adds its ID here. The Settings reset button clears this array. + +**`lastSeenVersionAtom`** — `atomWithLocalStorage("dispatch:lastSeenVersion", null)` +The app version from the user's previous session. On app load, tips with `since` between `lastSeenVersion` and the current version are candidates for inline display. After comparison, update to the current version. First-time users (`null`) get no inline tips — they haven't upgraded, so nothing is "new." They discover features through the ambient bar over time. + +### Shared Hook + +`useTip(id: string)` is the hook both surfaces use. It reads from the registry and state atoms, returning: + +- `tip` — the tip definition (or null if ID not found) +- `shouldShowInline` — true if the tip is undismissed, tips are enabled, and the tip's version is newer than `lastSeenVersion` +- `shouldShowAmbient` — true if the tip is undismissed, tips are enabled (no version gating for ambient) +- `dismiss()` — adds the tip ID to `dismissedTipsAtom` +- `disableAll()` — sets `tipsEnabledAtom` to false + +## Surface 1: Inline Popover (TipSpot) + +### Usage + +Wrap the target element with a `TipSpot` component at the usage site: + +```tsx + + + +``` + +When the tip should not show (already dismissed, tips disabled, version not applicable), `TipSpot` renders its child transparently with zero overhead. + +### Behavior + +- **Auto-open on mount** after a ~500ms delay so the UI has settled. +- **One at a time** — a `TipQueueProvider` context ensures only one popover is open. First eligible tip in mount order wins; others wait until it's dismissed. +- **No sequential tour** — tips are independent. No step counter, no "next" button. If another tip is queued, it appears after the current one is dismissed. + +### Popover Content + +- Title (bold) with a small accent icon (sparkle or similar) +- Body text (1-2 lines) +- "Learn more →" link to docs (if `docsPath` is set) +- Dismiss `✕` button (top right) +- "Don't show tips" link (bottom right) — disables the entire system + +### Dismissal + +- Clicking `✕`, clicking outside the popover, or pressing Escape all permanently dismiss the tip. +- "Don't show tips" sets `tipsEnabledAtom` to false, hiding all tips everywhere. + +### Styling + +Uses the existing Radix Popover component. Glass overlay aesthetic with a subtle purple accent border to distinguish from regular popovers. Fade + scale entrance animation matching the existing popover pattern (zoom-in-95, ~200ms, ease-out). + +## Surface 2: Ambient Tip Bar + +### Placement + +The existing bottom buffer zone below the terminal area. A single subtle line of text. + +### Idle Detection & Display Logic + +1. An inactivity timer starts when there is no keyboard/mouse activity. Agent activity is not considered — tips can appear while an agent is running, since that's often when users are idle and waiting. +2. After a base delay (~2-3 minutes of inactivity), a random roll determines whether to show a tip (~40% chance). +3. If the roll fails, reset and wait another interval. +4. If the roll succeeds, pick a random undismissed tip eligible for the ambient surface. Avoid repeating the same tip within the same browser session (tracked in memory, not localStorage). +5. The tip fades in gently. + +### Auto-Hide Timer + +Once visible, a 30-second countdown begins: + +- If the user hovers over the tip bar, the countdown pauses. +- When the user moves away, the countdown resumes. +- When the countdown expires, the tip fades out quietly. +- The tip is **not** dismissed on auto-hide — it returns to the pool and could appear in a future idle period. +- Only clicking `✕` permanently dismisses the tip. + +### Content Layout + +A single line: + +``` +💡 Personas — Launch specialized review agents with structured feedback. Learn more → · Don't show tips ✕ +``` + +- Lightbulb icon (subtle opacity) +- Feature name (slightly brighter weight) +- Short description +- "Learn more →" link to docs +- "Don't show tips" link — disables the entire system +- Dismiss `✕` — permanently dismisses this specific tip + +### Styling + +Subtle top border separator. Very low-contrast background (nearly transparent). Text at reduced opacity. The bar should feel ambient, not attention-grabbing. + +## Settings Integration + +In the existing Notifications settings section, add: + +- **"Show tips" toggle** — controls `tipsEnabledAtom`. When off, no inline popovers auto-open and the ambient bar never shows tips. +- **"Reset dismissed tips" button** — clears the `dismissedTipsAtom` array. Shows a confirmation toast ("Tips reset — you'll see them again as you use the app"). + +## File Structure + +``` +apps/web/src/ +├── lib/ +│ └── tips/ +│ ├── tips.ts # tip registry (array of tip definitions) +│ ├── tips-state.ts # Jotai atoms: tipsEnabled, dismissedTips, lastSeenVersion +│ └── use-tip.ts # hook: useTip(id) → { tip, shouldShowInline, shouldShowAmbient, dismiss, disableAll } +├── components/ +│ └── tips/ +│ ├── tip-spot.tsx # wrapper component (inline popover) +│ ├── tip-popover.tsx # popover content (title, body, learn more, dismiss, don't show) +│ ├── ambient-tip-bar.tsx # bottom bar with idle detection + auto-hide timer +│ └── tip-queue-provider.tsx # context that ensures one inline popover at a time +``` + +- `TipQueueProvider` wraps the app layout and coordinates which `TipSpot` gets to open its popover. +- `AmbientTipBar` lives in the layout near the bottom buffer. It handles idle detection, random roll, hover-pause timer, and tip selection. +- Settings controls go in the existing notifications settings component. + +## Testing Strategy + +- **Unit tests** for `useTip` hook: version comparison logic, dismissal state, enable/disable. +- **Unit tests** for idle detection and timer logic in the ambient bar (mock timers). +- **E2E test** for inline popover: wrap a test element with `TipSpot`, verify popover appears and can be dismissed. +- **E2E test** for ambient bar: trigger idle conditions, verify tip appears and auto-hides. +- **E2E test** for settings: toggle tips off, verify neither surface shows. Reset tips, verify they reappear. + +## Starter Tips + +The initial registry should ship with a handful of tips for existing features to validate the system: + +- Quick Phrases +- Personas +- Brain (shared memory) +- Job scheduler +- Media sidebar + +These all have existing docs pages and clear UI anchors. New tips get added as part of the feature development process — one registry entry plus a `TipSpot` wrapper at the usage site. From 7f5380f889172e15c0420aad4c4e4d7a09a04e34 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:45:22 -0600 Subject: [PATCH 02/28] Add implementation plan for guided tips feature Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-06-11-guided-tips.md | 1357 +++++++++++++++++ 1 file changed, 1357 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-guided-tips.md diff --git a/docs/superpowers/plans/2026-06-11-guided-tips.md b/docs/superpowers/plans/2026-06-11-guided-tips.md new file mode 100644 index 00000000..37cbd58a --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-guided-tips.md @@ -0,0 +1,1357 @@ +# Guided Tips Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a feature discovery system with two surfaces — inline popovers on new features and an ambient tip bar — backed by a shared tip registry and Jotai state. + +**Architecture:** A unified tip registry (`tips.ts`) defines all tips. Jotai atoms (`atomWithLocalStorage`) track dismissal state and the user's last-seen version. Two independent UI surfaces consume this shared state: `TipSpot` (a wrapper component that renders a Radix Popover anchored to its child) and `AmbientTipBar` (an idle-triggered bottom bar). A `TipQueueProvider` context coordinates inline popovers so only one shows at a time. + +**Tech Stack:** React, Jotai, Radix UI Popover, Tailwind CSS, Vitest + +**Spec:** `docs/superpowers/specs/2026-06-11-guided-tips-design.md` + +--- + +## File Map + +| File | Responsibility | +| ------------------------------------------------------- | -------------------------------------------------------------------------- | +| `apps/web/src/lib/tips/tips.ts` | Tip definitions array and `Tip` type | +| `apps/web/src/lib/tips/tips-state.ts` | Jotai atoms: `tipsEnabledAtom`, `dismissedTipsAtom`, `lastSeenVersionAtom` | +| `apps/web/src/lib/tips/use-tip.ts` | `useTip(id)` hook — reads registry + state, returns visibility and actions | +| `apps/web/src/lib/tips/version-compare.ts` | Simple semver comparison (`isVersionNewer`) | +| `apps/web/src/components/tips/tip-popover-content.tsx` | Popover body: title, description, learn more, dismiss, don't show | +| `apps/web/src/components/tips/tip-spot.tsx` | `` wrapper — renders Radix Popover around its child | +| `apps/web/src/components/tips/tip-queue-provider.tsx` | Context provider: ensures one inline popover at a time | +| `apps/web/src/components/tips/ambient-tip-bar.tsx` | Bottom bar: idle detection, random roll, auto-hide timer, hover pause | +| `apps/web/src/lib/store.ts` | Modified — export new tip atoms | +| `apps/web/src/App.tsx` | Modified — wrap layout in `TipQueueProvider` | +| `apps/web/src/components/app/agents-view.tsx` | Modified — add `AmbientTipBar` in bottom buffer area | +| `apps/web/src/components/app/notification-settings.tsx` | Modified — add Tips section with toggle and reset button | + +--- + +### Task 1: Version Comparison Utility + +**Files:** + +- Create: `apps/web/src/lib/tips/version-compare.ts` +- Test: `apps/web/src/lib/tips/__tests__/version-compare.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/web/src/lib/tips/__tests__/version-compare.test.ts`: + +```ts +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/version-compare.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +Create `apps/web/src/lib/tips/version-compare.ts`: + +```ts +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; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/version-compare.test.ts` +Expected: All 7 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/tips/version-compare.ts apps/web/src/lib/tips/__tests__/version-compare.test.ts +git commit -m "feat(tips): add semver comparison utility" +``` + +--- + +### Task 2: Tip Registry + +**Files:** + +- Create: `apps/web/src/lib/tips/tips.ts` +- Test: `apps/web/src/lib/tips/__tests__/tips.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/lib/tips/__tests__/tips.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { type Surface, type Tip, 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); + } + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +Create `apps/web/src/lib/tips/tips.ts`: + +```ts +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: "shortcuts", + 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 access.", + docsSection: "events", + 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"], + }, +]; + +export function getTipById(id: string): Tip | undefined { + return tips.find((t) => t.id === id); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips.test.ts` +Expected: All 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/tips/tips.ts apps/web/src/lib/tips/__tests__/tips.test.ts +git commit -m "feat(tips): add tip registry with starter tips" +``` + +--- + +### Task 3: Tips State Atoms + +**Files:** + +- Create: `apps/web/src/lib/tips/tips-state.ts` +- Test: `apps/web/src/lib/tips/__tests__/tips-state.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/lib/tips/__tests__/tips-state.test.ts`: + +```ts +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "../tips-state"; + +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"] + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips-state.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +Create `apps/web/src/lib/tips/tips-state.ts`: + +```ts +import { atomWithLocalStorage } from "../store"; + +export const tipsEnabledAtom = atomWithLocalStorage( + "dispatch:tipsEnabled", + true +); + +export const dismissedTipsAtom = atomWithLocalStorage( + "dispatch:dismissedTips", + [] +); + +export const lastSeenVersionAtom = atomWithLocalStorage( + "dispatch:lastSeenVersion", + null +); +``` + +**Important:** `atomWithLocalStorage` is currently not exported from `apps/web/src/lib/store.ts`. Add the export: + +In `apps/web/src/lib/store.ts`, change the function declaration from: + +```ts +function atomWithLocalStorage(key: string, initialValue: T) { +``` + +to: + +```ts +export function atomWithLocalStorage(key: string, initialValue: T) { +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips-state.test.ts` +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/tips/tips-state.ts apps/web/src/lib/tips/__tests__/tips-state.test.ts apps/web/src/lib/store.ts +git commit -m "feat(tips): add Jotai atoms for tips state" +``` + +--- + +### Task 4: useTip Hook + +**Files:** + +- Create: `apps/web/src/lib/tips/use-tip.ts` +- Test: `apps/web/src/lib/tips/__tests__/use-tip.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/web/src/lib/tips/__tests__/use-tip.test.ts`: + +```ts +import { createStore, Provider } from "jotai"; +import { renderHook } from "@testing-library/react"; +import { act } from "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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/use-tip.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Write implementation** + +Create `apps/web/src/lib/tips/use-tip.ts`: + +```ts +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 }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/use-tip.test.ts` +Expected: All 8 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/tips/use-tip.ts apps/web/src/lib/tips/__tests__/use-tip.test.ts +git commit -m "feat(tips): add useTip hook with version gating" +``` + +--- + +### Task 5: TipQueueProvider + +**Files:** + +- Create: `apps/web/src/components/tips/tip-queue-provider.tsx` + +- [ ] **Step 1: Write the provider** + +Create `apps/web/src/components/tips/tip-queue-provider.tsx`: + +```tsx +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} + + ); +} +``` + +- [ ] **Step 2: Wire into App.tsx** + +In `apps/web/src/App.tsx`, import the provider: + +```ts +import { TipQueueProvider } from "@/components/tips/tip-queue-provider"; +``` + +Wrap the return value in `DashboardLayout` (around line 242): + +```tsx +return ( + + + + + + +); +``` + +- [ ] **Step 3: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/tips/tip-queue-provider.tsx apps/web/src/App.tsx +git commit -m "feat(tips): add TipQueueProvider for single-popover coordination" +``` + +--- + +### Task 6: TipPopoverContent Component + +**Files:** + +- Create: `apps/web/src/components/tips/tip-popover-content.tsx` + +- [ ] **Step 1: Write the component** + +Create `apps/web/src/components/tips/tip-popover-content.tsx`: + +```tsx +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 ? ( + + ) : ( + + )} + +
+
+ ); +} +``` + +- [ ] **Step 2: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tips/tip-popover-content.tsx +git commit -m "feat(tips): add TipPopoverContent component" +``` + +--- + +### Task 7: TipSpot Wrapper Component + +**Files:** + +- Create: `apps/web/src/components/tips/tip-spot.tsx` + +- [ ] **Step 1: Write the component** + +Create `apps/web/src/components/tips/tip-spot.tsx`: + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; + +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"; + +type TipSpotProps = { + tipId: string; + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + sideOffset?: number; + onOpenDocs?: (section: string) => void; + children: React.ReactNode; +}; + +export function TipSpot({ + tipId, + side = "right", + align = "center", + sideOffset = 8, + onOpenDocs, + children, +}: TipSpotProps) { + const { tip, shouldShowInline, dismiss, disableAll } = useTip(tipId); + const { requestOpen, release } = useTipQueue(); + const [open, setOpen] = useState(false); + const mountedRef = useRef(false); + + useEffect(() => { + if (!shouldShowInline || mountedRef.current) return; + mountedRef.current = true; + + const timer = setTimeout(() => { + if (requestOpen(tipId)) { + setOpen(true); + } + }, 500); + + return () => clearTimeout(timer); + }, [shouldShowInline, tipId, requestOpen]); + + 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) { + handleDismiss(); + } + }, + [handleDismiss] + ); + + if (!tip || !shouldShowInline) { + return <>{children}; + } + + return ( + + {children} + e.preventDefault()} + > + + + + ); +} +``` + +- [ ] **Step 2: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tips/tip-spot.tsx +git commit -m "feat(tips): add TipSpot wrapper component" +``` + +--- + +### Task 8: AmbientTipBar Component + +**Files:** + +- Create: `apps/web/src/components/tips/ambient-tip-bar.tsx` + +- [ ] **Step 1: Write the component** + +Create `apps/web/src/components/tips/ambient-tip-bar.tsx`: + +```tsx +import { AnimatePresence, motion } from "framer-motion"; +import { Lightbulb, X } from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { tips, type Tip } from "@/lib/tips/tips"; +import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; + +const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes +const SHOW_CHANCE = 0.4; +const AUTO_HIDE_MS = 30_000; + +export function AmbientTipBar({ + onOpenDocs, +}: { + onOpenDocs?: (section: string) => void; +}) { + const enabled = useAtomValue(tipsEnabledAtom); + const dismissed = useAtomValue(dismissedTipsAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + const setEnabled = useSetAtom(tipsEnabledAtom); + + const [visibleTip, setVisibleTip] = useState(null); + const shownThisSessionRef = useRef(new Set()); + const hoveredRef = useRef(false); + const autoHideTimerRef = useRef>(); + const idleTimerRef = 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]); + + useEffect(() => { + if (!enabled) { + setVisibleTip(null); + return; + } + + const onActivity = () => { + if (visibleTip) return; + 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); + clearTimeout(autoHideTimerRef.current); + }, [setEnabled]); + + return ( + + {visibleTip ? ( + +
+
+ + + + {visibleTip.title} + + + {visibleTip.body} + + {visibleTip.docsSection ? ( + + ) : null} +
+
+ + +
+
+
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 2: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/tips/ambient-tip-bar.tsx +git commit -m "feat(tips): add AmbientTipBar with idle detection and auto-hide" +``` + +--- + +### Task 9: Wire AmbientTipBar into AgentsView + +**Files:** + +- Modify: `apps/web/src/components/app/agents-view.tsx` + +The ambient tip bar goes in the bottom buffer area of the terminal pane. The terminal content div at line 579 has `pb-14` (56px bottom padding) — this is the buffer zone. + +- [ ] **Step 1: Add import** + +At the top of `apps/web/src/components/app/agents-view.tsx`, add: + +```ts +import { AmbientTipBar } from "@/components/tips/ambient-tip-bar"; +``` + +- [ ] **Step 2: Add the tip bar** + +Find the `pb-14` div (line 579): + +```tsx +
+``` + +After the `` closing tag (line 627) and before the closing `
` of the `pb-14` container, add the ambient tip bar absolutely positioned in the bottom padding zone: + +```tsx +
+ +
+``` + +The full section after the edit (lines ~627-630): + +```tsx + /> +
+ +
+ +``` + +This positions the bar inside the 56px (`pb-14`) bottom buffer without affecting terminal layout. + +- [ ] **Step 3: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/app/agents-view.tsx +git commit -m "feat(tips): wire AmbientTipBar into agents view bottom buffer" +``` + +--- + +### Task 10: Wire lastSeenVersion Initialization + +**Files:** + +- Modify: `apps/web/src/App.tsx` + +The `lastSeenVersionAtom` needs to be read on app load, compared against `BUILD_VERSION`, and then updated. This should happen once per session. + +- [ ] **Step 1: Create version initializer** + +Add a small component in `apps/web/src/App.tsx` (above `DashboardLayout`): + +```tsx +import { useAtom } from "jotai"; +import { useEffect, useRef } from "react"; + +import { BUILD_VERSION } from "@/lib/version"; +import { lastSeenVersionAtom } from "@/lib/tips/tips-state"; + +function TipsVersionInit() { + const [lastSeen, setLastSeen] = useAtom(lastSeenVersionAtom); + const didInit = useRef(false); + + useEffect(() => { + if (didInit.current) return; + didInit.current = true; + if (lastSeen !== BUILD_VERSION) { + // Delay the update so TipSpot components can read the old + // lastSeenVersion during this render cycle and decide which + // inline tips to show. + const timer = setTimeout(() => setLastSeen(BUILD_VERSION), 2000); + return () => clearTimeout(timer); + } + }, [lastSeen, setLastSeen]); + + return null; +} +``` + +- [ ] **Step 2: Render inside DashboardLayout** + +In the `DashboardLayout` return (around line 242), add `` inside the `TipQueueProvider`: + +```tsx +return ( + + + + + + + +); +``` + +- [ ] **Step 3: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/App.tsx +git commit -m "feat(tips): initialize lastSeenVersion on app load" +``` + +--- + +### Task 11: Settings Integration + +**Files:** + +- Modify: `apps/web/src/components/app/notification-settings.tsx` + +- [ ] **Step 1: Add TipsSection component** + +At the top of `notification-settings.tsx`, add imports: + +```ts +import { useAtom, useSetAtom } from "jotai"; +import { RotateCcw } from "lucide-react"; +import { toast } from "sonner"; + +import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; +``` + +After the `SoundCuesSection` component (line 92), add: + +```tsx +function TipsSection(): JSX.Element { + const [enabled, setEnabled] = useAtom(tipsEnabledAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + + return ( +
+

+ Tips & Guidance +

+

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

+
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Render TipsSection** + +Find where `` is rendered inside `NotificationSettings`. It should be near the top of the component's JSX. Add `` right after it, with a border separator: + +```tsx + + +
+ +
+``` + +- [ ] **Step 3: Verify existing imports** + +Check that `Checkbox`, `Button`, `toast` are already imported in the file. If not, add them. `Checkbox` and `Button` are likely already imported (used by SoundCuesSection). `toast` from `sonner` may need to be added. + +- [ ] **Step 4: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/components/app/notification-settings.tsx +git commit -m "feat(tips): add tips toggle and reset button to notification settings" +``` + +--- + +### Task 12: Add a TipSpot to an Existing Feature + +**Files:** + +- Modify: `apps/web/src/components/app/agents-view.tsx` (QuickPhrasesButton area) + +Wire up one `TipSpot` to validate the full inline flow end-to-end. + +- [ ] **Step 1: Wrap QuickPhrasesButton with TipSpot** + +In `apps/web/src/components/app/agents-view.tsx`, import TipSpot: + +```ts +import { TipSpot } from "@/components/tips/tip-spot"; +``` + +Find the `` usage (around line 570): + +```tsx + +``` + +Wrap it: + +```tsx + + + +``` + +- [ ] **Step 2: Run type check** + +Run: `pnpm run check` +Expected: No type errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/app/agents-view.tsx +git commit -m "feat(tips): add TipSpot to QuickPhrasesButton" +``` + +--- + +### Task 13: Finalize and Validate + +- [ ] **Step 1: Run full type check** + +Run: `pnpm run check` +Expected: No type errors across the workspace + +- [ ] **Step 2: Run web finalization** + +Run: `pnpm run finalize:web` +Expected: Type check and production build succeed + +- [ ] **Step 3: Run unit tests** + +Run: `pnpm vitest run apps/web/src/lib/tips/` +Expected: All tests pass + +- [ ] **Step 4: Run E2E tests** + +Run: `pnpm run test:e2e` +Expected: All existing tests pass (no regressions) + +- [ ] **Step 5: Visual validation** + +Start a dev server using `repo_dev_up` and validate in Playwright: + +1. Open the app +2. Set `dispatch:lastSeenVersion` in localStorage to a version older than "0.23.0" +3. Reload — verify the Quick Phrases TipSpot popover appears +4. Dismiss it — verify it doesn't reappear on reload +5. Go to Settings > Notifications — verify Tips section with toggle and reset button +6. Click "Reset dismissed tips" — verify toast appears +7. Reload — verify the tip can appear again +8. Toggle "Show tips" off — verify no tips appear + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A +git commit -m "fix(tips): address validation feedback" +``` From 20b213e804b51a2dda6285ce5b88f10265fa460d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:48:52 -0600 Subject: [PATCH 03/28] feat(tips): add semver comparison utility Co-Authored-By: Claude Opus 4.6 --- .../tips/__tests__/version-compare.test.ts | 33 +++++++++++++++++++ apps/web/src/lib/tips/version-compare.ts | 13 ++++++++ 2 files changed, 46 insertions(+) create mode 100644 apps/web/src/lib/tips/__tests__/version-compare.test.ts create mode 100644 apps/web/src/lib/tips/version-compare.ts 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/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; +} From 020038eceb4c9936c5f9b492c3ef3cd40315a28e Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:50:56 -0600 Subject: [PATCH 04/28] feat(tips): add tip registry with starter tips Implements the central registry for tip definitions with 5 starter tips covering Quick Phrases, Personas, Brain, Automations, and Media Sidebar. Each tip includes ID, title, body, optional docs reference, version introduced (since), and surfaces (inline/ambient). Includes getTipById helper and comprehensive test suite validating structure and uniqueness. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/tips/tips.test.ts | 27 ++++++++++++++ apps/web/src/lib/tips/tips.ts | 57 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 apps/web/src/lib/tips/tips.test.ts create mode 100644 apps/web/src/lib/tips/tips.ts diff --git a/apps/web/src/lib/tips/tips.test.ts b/apps/web/src/lib/tips/tips.test.ts new file mode 100644 index 00000000..cf8c9def --- /dev/null +++ b/apps/web/src/lib/tips/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/tips.ts b/apps/web/src/lib/tips/tips.ts new file mode 100644 index 00000000..e5078b16 --- /dev/null +++ b/apps/web/src/lib/tips/tips.ts @@ -0,0 +1,57 @@ +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: "shortcuts", + 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 access.", + docsSection: "events", + 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"], + }, +]; + +export function getTipById(id: string): Tip | undefined { + return tips.find((t) => t.id === id); +} From eff7f7e6749de66ed8d42ca4fb27e85fa798730d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:51:53 -0600 Subject: [PATCH 05/28] chore: move tips test to __tests__ directory Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/tips/{ => __tests__}/tips.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/web/src/lib/tips/{ => __tests__}/tips.test.ts (95%) diff --git a/apps/web/src/lib/tips/tips.test.ts b/apps/web/src/lib/tips/__tests__/tips.test.ts similarity index 95% rename from apps/web/src/lib/tips/tips.test.ts rename to apps/web/src/lib/tips/__tests__/tips.test.ts index cf8c9def..2877eb29 100644 --- a/apps/web/src/lib/tips/tips.test.ts +++ b/apps/web/src/lib/tips/__tests__/tips.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { tips } from "./tips"; +import { tips } from "../tips"; describe("tips registry", () => { it("exports a non-empty array of tips", () => { From de2443048cc774208e14e155721586b9af64256c Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:55:26 -0600 Subject: [PATCH 06/28] feat(tips): add Jotai atoms for tips state Create tips-state.ts with three persistent atoms for tips feature: - tipsEnabledAtom: master toggle (defaults to true) - dismissedTipsAtom: tracks dismissed tip IDs (defaults to []) - lastSeenVersionAtom: enables version-gating (defaults to null) All atoms use atomWithLocalStorage for JSON serialization and cross-tab sync. Export atomWithLocalStorage from store.ts to support these atoms. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/store.ts | 2 +- .../src/lib/tips/__tests__/tips-state.test.ts | 45 +++++++++++++++++++ apps/web/src/lib/tips/tips-state.ts | 16 +++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/tips/__tests__/tips-state.test.ts create mode 100644 apps/web/src/lib/tips/tips-state.ts 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/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 +); From 479351f6f173951941a26f8f78f1f84bad97f0a9 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 20:59:17 -0600 Subject: [PATCH 07/28] feat(tips): add useTip hook with version gating Add @testing-library/react and jsdom dev deps, configure vitest with react plugin and __DISPATCH_VERSION__ define, and implement the useTip hook that combines tip registry, state atoms, and version comparison to drive inline and ambient tip visibility. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 2 + .../src/lib/tips/__tests__/use-tip.test.tsx | 82 ++++++ apps/web/src/lib/tips/use-tip.ts | 44 ++++ apps/web/vitest.config.ts | 5 + pnpm-lock.yaml | 241 ++++++++++++------ 5 files changed, 299 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/lib/tips/__tests__/use-tip.test.tsx create mode 100644 apps/web/src/lib/tips/use-tip.ts 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/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/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/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/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: {} From 794a7c2030598cd324b31e401cb2e899fd283652 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:01:52 -0600 Subject: [PATCH 08/28] feat(tips): add TipPopoverContent component Co-Authored-By: Claude Opus 4.6 --- .../components/tips/tip-popover-content.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/web/src/components/tips/tip-popover-content.tsx 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 ? ( + + ) : ( + + )} + +
+
+ ); +} From cf79e2ffdb7f161042bd676685e65f507cf6677b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:02:03 -0600 Subject: [PATCH 09/28] feat(tips): add TipQueueProvider for single-popover coordination Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/App.tsx | 5 +- .../components/tips/tip-queue-provider.tsx | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/tips/tip-queue-provider.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e272406..828b4fe5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -28,6 +28,7 @@ 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 { agentRoute } from "@/lib/agent-routes"; import { ReleaseAvailableToast } from "@/components/app/release-available-toast"; import { UpdateAvailableToast } from "@/components/app/update-available-toast"; @@ -240,7 +241,7 @@ export function DashboardLayout(): JSX.Element { }; return ( - <> + @@ -254,7 +255,7 @@ export function DashboardLayout(): JSX.Element { richColors toastOptions={{ duration: 3000 }} /> - + ); } 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} + + ); +} From 8947e067003c07abb86e5506c9d2dd233a6e41ca Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:03:48 -0600 Subject: [PATCH 10/28] feat(tips): add TipSpot wrapper component Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/web/src/components/tips/tip-spot.tsx 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..be722de1 --- /dev/null +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +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"; + +type TipSpotProps = { + tipId: string; + side?: "top" | "bottom" | "left" | "right"; + align?: "start" | "center" | "end"; + sideOffset?: number; + onOpenDocs?: (section: string) => void; + children: React.ReactNode; +}; + +export function TipSpot({ + tipId, + side = "right", + align = "center", + sideOffset = 8, + onOpenDocs, + children, +}: TipSpotProps) { + const { tip, shouldShowInline, dismiss, disableAll } = useTip(tipId); + const { requestOpen, release } = useTipQueue(); + const [open, setOpen] = useState(false); + const mountedRef = useRef(false); + + useEffect(() => { + if (!shouldShowInline || mountedRef.current) return; + mountedRef.current = true; + + const timer = setTimeout(() => { + if (requestOpen(tipId)) { + setOpen(true); + } + }, 500); + + return () => clearTimeout(timer); + }, [shouldShowInline, tipId, requestOpen]); + + 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) { + handleDismiss(); + } + }, + [handleDismiss] + ); + + if (!tip || !shouldShowInline) { + return <>{children}; + } + + return ( + + {children} + e.preventDefault()} + > + + + + ); +} From e1c327a6fd2027ee7f00d05143f3048aaf703588 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:04:26 -0600 Subject: [PATCH 11/28] feat(tips): add AmbientTipBar with idle detection and auto-hide Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/tips/ambient-tip-bar.tsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 apps/web/src/components/tips/ambient-tip-bar.tsx 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..ea56fd5f --- /dev/null +++ b/apps/web/src/components/tips/ambient-tip-bar.tsx @@ -0,0 +1,167 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { Lightbulb, X } from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { tips, type Tip } from "@/lib/tips/tips"; +import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; + +const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes +const SHOW_CHANCE = 0.4; +const AUTO_HIDE_MS = 30_000; + +export function AmbientTipBar({ + onOpenDocs, +}: { + onOpenDocs?: (section: string) => void; +}) { + const enabled = useAtomValue(tipsEnabledAtom); + const dismissed = useAtomValue(dismissedTipsAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + const setEnabled = useSetAtom(tipsEnabledAtom); + + const [visibleTip, setVisibleTip] = useState(null); + const shownThisSessionRef = useRef(new Set()); + const hoveredRef = useRef(false); + const autoHideTimerRef = useRef>(); + const idleTimerRef = 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]); + + useEffect(() => { + if (!enabled) { + setVisibleTip(null); + return; + } + + const onActivity = () => { + if (visibleTip) return; + 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); + clearTimeout(autoHideTimerRef.current); + }, [setEnabled]); + + return ( + + {visibleTip ? ( + +
+
+ + + + {visibleTip.title} + + + {visibleTip.body} + + {visibleTip.docsSection ? ( + + ) : null} +
+
+ + +
+
+
+ ) : null} +
+ ); +} From 39f9b4b6c0ae396a4fdc7ba1760c7d39ac9532b4 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:06:15 -0600 Subject: [PATCH 12/28] feat(tips): wire AmbientTipBar and TipSpot into agents view Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/components/app/agents-view.tsx | 23 ++++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 9e863944..c4904f69 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,9 @@ export function AgentsView({ : null } /> +
+ +
{!isMobile ? ( From 005041bac4dfd6363419c43a6ab93a30382ceac6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:07:10 -0600 Subject: [PATCH 13/28] feat(tips): add version init and settings integration Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/App.tsx | 20 +++++++ .../components/app/notification-settings.tsx | 52 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 828b4fe5..5f02aa24 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useAtom } from "jotai"; import { Outlet, useMatches, @@ -29,6 +30,8 @@ import { import { type IdeType, sanitizeEnabledIdes } from "@/lib/ide-types"; import { sortAgentsByCreatedAtDesc } from "@/lib/agent-sort"; import { TipQueueProvider } from "@/components/tips/tip-queue-provider"; +import { BUILD_VERSION } from "@/lib/version"; +import { lastSeenVersionAtom } from "@/lib/tips/tips-state"; import { agentRoute } from "@/lib/agent-routes"; import { ReleaseAvailableToast } from "@/components/app/release-available-toast"; import { UpdateAvailableToast } from "@/components/app/update-available-toast"; @@ -73,6 +76,22 @@ export function useDashboardContext(): DashboardContextValue { return useOutletContext(); } +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; +} + export function DashboardLayout(): JSX.Element { const navigate = useNavigate(); const matches = useMatches() as Array<{ handle?: RouteHandle }>; @@ -242,6 +261,7 @@ export function DashboardLayout(): JSX.Element { return ( + diff --git a/apps/web/src/components/app/notification-settings.tsx b/apps/web/src/components/app/notification-settings.tsx index 95477843..230c6b8c 100644 --- a/apps/web/src/components/app/notification-settings.tsx +++ b/apps/web/src/components/app/notification-settings.tsx @@ -1,11 +1,13 @@ 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, tipsEnabledAtom } from "@/lib/tips/tips-state"; import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues"; import { getNotificationPermission, @@ -91,6 +93,48 @@ function SoundCuesSection(): JSX.Element { ); } +function TipsSection(): JSX.Element { + const [enabled, setEnabled] = useAtom(tipsEnabledAtom); + const setDismissed = useSetAtom(dismissedTipsAtom); + + 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 +410,10 @@ export function NotificationSettings(): JSX.Element {
+
+ +
+ {/* Browser Notifications */}

From 74cce1dcc4e48333a6f65b72e8d050b3f9ececbe Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 21:29:56 -0600 Subject: [PATCH 14/28] fix(tips): fix TipSpot popover not appearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented the inline popover from rendering: 1. React StrictMode double-invokes effects (run → cleanup → run). The mountedRef guard persisted across the cleanup cycle, so the second invocation returned early and never started the open timer. Fix: remove the mountedRef and let the effect lifecycle handle it. 2. PopoverTrigger with asChild tried to forward a ref to the child component, but function components without forwardRef silently fail. Fix: wrap children in a so Radix can attach its ref. Also latch eligibility via eligibleRef so the popover survives lastSeenVersion atom updates from TipsVersionInit. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index be722de1..0f677ae7 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -30,11 +30,15 @@ export function TipSpot({ const { tip, shouldShowInline, dismiss, disableAll } = useTip(tipId); const { requestOpen, release } = useTipQueue(); const [open, setOpen] = useState(false); - const mountedRef = useRef(false); + const eligibleRef = useRef(false); + + // Latch eligibility so the popover survives lastSeenVersion updates + if (shouldShowInline) { + eligibleRef.current = true; + } useEffect(() => { - if (!shouldShowInline || mountedRef.current) return; - mountedRef.current = true; + if (!shouldShowInline) return; const timer = setTimeout(() => { if (requestOpen(tipId)) { @@ -66,13 +70,15 @@ export function TipSpot({ [handleDismiss] ); - if (!tip || !shouldShowInline) { + if (!tip || !eligibleRef.current) { return <>{children}; } return ( - {children} + + {children} + Date: Thu, 11 Jun 2026 22:19:50 -0600 Subject: [PATCH 15/28] fix(tips): improve popover positioning and add arrow - Replace display:contents span with inline-flex so Radix has a bounding box to anchor the popover against - Add PopoverArrow pointing from popover to the trigger element - Center-align the Quick Phrases popover on the icon instead of start-aligning Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 2 +- apps/web/src/components/tips/tip-spot.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index c4904f69..89a08c19 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -569,7 +569,7 @@ export function AgentsView({ ) : null} - + - {children} + {children} e.preventDefault()} > + Date: Thu, 11 Jun 2026 22:26:47 -0600 Subject: [PATCH 16/28] fix(tips): reset lastSeenVersion when resetting tips Hitting "Reset dismissed tips" in settings now also sets lastSeenVersion to "0.0.0", so inline tips reappear on the next page load. Previously it only cleared the dismissed list, which meant inline tips stayed hidden because they weren't considered "new" relative to the current version. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/notification-settings.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/app/notification-settings.tsx b/apps/web/src/components/app/notification-settings.tsx index 230c6b8c..6687425e 100644 --- a/apps/web/src/components/app/notification-settings.tsx +++ b/apps/web/src/components/app/notification-settings.tsx @@ -7,7 +7,11 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { api } from "@/lib/api"; import { soundCuesEnabledAtom } from "@/lib/store"; -import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; +import { + dismissedTipsAtom, + lastSeenVersionAtom, + tipsEnabledAtom, +} from "@/lib/tips/tips-state"; import { CUE_INTENTS, playCueForIntent, playTapCue } from "@/lib/sound-cues"; import { getNotificationPermission, @@ -96,6 +100,7 @@ function SoundCuesSection(): JSX.Element { function TipsSection(): JSX.Element { const [enabled, setEnabled] = useAtom(tipsEnabledAtom); const setDismissed = useSetAtom(dismissedTipsAtom); + const setLastSeenVersion = useSetAtom(lastSeenVersionAtom); return (
@@ -120,6 +125,7 @@ function TipsSection(): JSX.Element { size="sm" onClick={() => { setDismissed([]); + setLastSeenVersion("0.0.0"); toast.success( "Tips reset — you'll see them again as you use the app." ); From 743ac60496d57e26f8acca85d764fb51421a968d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 11 Jun 2026 22:31:22 -0600 Subject: [PATCH 17/28] fix(tips): style arrow border to match popover Use fill-[hsl(var(--card))] to match the popover background and stroke-white/20 on the SVG polygon to match the glassOverlay border. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index bfdd673c..1f29df48 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -91,7 +91,7 @@ export function TipSpot({ Date: Thu, 11 Jun 2026 23:00:34 -0600 Subject: [PATCH 18/28] fix(tips): solid border-colored arrow on tip popover Fill the arrow with white/20 to match the glassOverlay border color, making it look like the border itself extends outward as a triangle. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index 1f29df48..8a00b0b8 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -89,9 +89,9 @@ export function TipSpot({ onOpenAutoFocus={(e) => e.preventDefault()} > Date: Thu, 11 Jun 2026 23:07:42 -0600 Subject: [PATCH 19/28] fix(tips): match arrow colors to popover border and background Arrow border uses purple-500/20 to match popover's border-purple-500/20, and fill uses --popover background instead of --card. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 11 ++---- apps/web/src/index.css | 44 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index 8a00b0b8..56ccb102 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -1,7 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import * as PopoverPrimitive from "@radix-ui/react-popover"; - import { Popover, PopoverContent, @@ -84,15 +82,10 @@ export function TipSpot({ e.preventDefault()} > - Date: Fri, 12 Jun 2026 08:31:01 -0600 Subject: [PATCH 20/28] fix(tips): use rotated-square clip-path arrow for seamless base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace double-triangle technique with a rotated square clipped to show only the arrow half. The clip edge aligns with the popover border so the base is invisible — only the two diagonal edges are visible, making the arrow look like the popover border bulging outward. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/index.css | 47 ++++++++++++------------------------------ 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c47da650..d4236071 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1153,46 +1153,25 @@ html[data-hidden] .animate-sidebar-nav-pulse { background: hsl(var(--muted)) !important; } -/* Tip popover arrow — two stacked CSS triangles (border + fill). */ +/* Tip popover arrow — rotated square clipped to a triangle. + The clip edge aligns with the popover border for a seamless join. */ .tip-arrow[data-side="bottom"]::before, -.tip-arrow[data-side="bottom"]::after { +.tip-arrow[data-side="top"]::before { content: ""; position: absolute; - left: 50%; - border-left: 8px solid transparent; - border-right: 8px solid transparent; + width: 12px; + height: 12px; + left: calc(50% - 6px); + background: hsl(var(--popover)); + border: 1px solid rgba(168, 85, 247, 0.2); + transform: rotate(45deg); pointer-events: none; } .tip-arrow[data-side="bottom"]::before { - bottom: 100%; - margin-left: -8px; - border-bottom: 8px solid rgba(168, 85, 247, 0.2); -} -.tip-arrow[data-side="bottom"]::after { - bottom: 100%; - margin-left: -7px; - border-left: 7px solid transparent; - border-right: 7px solid transparent; - border-bottom: 7px solid hsl(var(--popover)); -} -.tip-arrow[data-side="top"]::before, -.tip-arrow[data-side="top"]::after { - content: ""; - position: absolute; - left: 50%; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - pointer-events: none; + top: -7px; + clip-path: polygon(50% 0, 0 50%, 100% 50%); } .tip-arrow[data-side="top"]::before { - top: 100%; - margin-left: -8px; - border-top: 8px solid rgba(168, 85, 247, 0.2); -} -.tip-arrow[data-side="top"]::after { - top: 100%; - margin-left: -7px; - border-left: 7px solid transparent; - border-right: 7px solid transparent; - border-top: 7px solid hsl(var(--popover)); + bottom: -7px; + clip-path: polygon(0 50%, 100% 50%, 50% 100%); } From 2586d55c8d849343833d5a4501c1b12080fa579f Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 12 Jun 2026 09:45:32 -0600 Subject: [PATCH 21/28] fix(tips): use inline SVG arrow for seamless popover integration Replace CSS pseudo-element rotated square with an inline SVG element. The SVG draws a filled polygon (popover bg) with an open path (border color, no base line) so the base edge is invisible. Removes backdrop- filter and inset shadow from the tip popover so the SVG fill matches the popover background exactly. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/tips/tip-spot.tsx | 43 ++++++++++++++++++++++- apps/web/src/index.css | 27 ++++---------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index 56ccb102..decd7f26 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -10,6 +10,46 @@ import { useTip } from "@/lib/tips/use-tip"; import { TipPopoverContent } from "./tip-popover-content"; import { useTipQueue } from "./tip-queue-provider"; +function TipArrow({ side }: { side: string }) { + const isBottom = side === "bottom" || side === "right"; + // Arrow dimensions: 16px wide, 8px tall + // Polygon fills the arrow interior, open path draws only the two diagonal edges. + // The SVG is positioned absolutely to overlap the popover border by 1px. + return ( + + {isBottom ? ( + <> + + + + ) : ( + <> + + + + )} + + ); +} + type TipSpotProps = { tipId: string; side?: "top" | "bottom" | "left" | "right"; @@ -83,9 +123,10 @@ export function TipSpot({ side={side} align={align} sideOffset={sideOffset + 8} - className="tip-arrow w-auto border-purple-500/20" + className="tip-popover w-auto border-purple-500/20" onOpenAutoFocus={(e) => e.preventDefault()} > + Date: Fri, 12 Jun 2026 10:17:58 -0600 Subject: [PATCH 22/28] fix(tips): Learn More navigation, arrow alignment, and mobile visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Learn More button navigates to /settings/help/:section#anchor with hash-based deep linking to specific doc headings - Internalize navigation in TipSpot and AmbientTipBar via useNavigate (removed onOpenDocs prop plumbing) - Arrow SVG offset tracks trigger position so it points at the referenced element, not the popover center - Tip won't open when trigger is occluded by mobile sidebar overlay (uses elementFromPoint check) - Polling retry ensures tip appears after sidebar closes - Fix docsSection mappings: quick-phrases → agents#quick-phrases, brain → tools#brain - Add id prop to H3 doc primitive for anchor targets Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/docs-pane.tsx | 20 ++- .../components/app/docs-sections/agents.tsx | 2 +- .../app/docs-sections/primitives.tsx | 12 +- .../components/app/docs-sections/tools.tsx | 2 +- .../src/components/tips/ambient-tip-bar.tsx | 12 +- apps/web/src/components/tips/tip-spot.tsx | 121 +++++++++++++++--- apps/web/src/lib/tips/tips.ts | 4 +- 7 files changed, 141 insertions(+), 32 deletions(-) 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/tips/ambient-tip-bar.tsx b/apps/web/src/components/tips/ambient-tip-bar.tsx index ea56fd5f..c390ff53 100644 --- a/apps/web/src/components/tips/ambient-tip-bar.tsx +++ b/apps/web/src/components/tips/ambient-tip-bar.tsx @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Lightbulb, X } 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"; @@ -10,11 +11,8 @@ const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes const SHOW_CHANCE = 0.4; const AUTO_HIDE_MS = 30_000; -export function AmbientTipBar({ - onOpenDocs, -}: { - onOpenDocs?: (section: string) => void; -}) { +export function AmbientTipBar() { + const navigate = useNavigate(); const enabled = useAtomValue(tipsEnabledAtom); const dismissed = useAtomValue(dismissedTipsAtom); const setDismissed = useSetAtom(dismissedTipsAtom); @@ -138,7 +136,9 @@ export function AmbientTipBar({ {visibleTip.docsSection ? (

{!isMobile ? ( diff --git a/apps/web/src/components/tips/ambient-tip-bar.tsx b/apps/web/src/components/tips/ambient-tip-bar.tsx index c390ff53..d4372fa5 100644 --- a/apps/web/src/components/tips/ambient-tip-bar.tsx +++ b/apps/web/src/components/tips/ambient-tip-bar.tsx @@ -1,15 +1,17 @@ import { AnimatePresence, motion } from "framer-motion"; -import { Lightbulb, X } from "lucide-react"; +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(); @@ -19,10 +21,12 @@ export function AmbientTipBar() { 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( @@ -109,59 +113,146 @@ export function AmbientTipBar() { 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 ( - - {visibleTip ? ( - -
-
- - - - {visibleTip.title} - - - {visibleTip.body} - - {visibleTip.docsSection ? ( - - ) : null} -
-
- +
+ {/* 1: lightbulb — always in the same spot */} + + + {/* 2: tip text — grows, wraps */} + + {visibleTip ? ( + + + {visibleTip.title} + + + {visibleTip.body} + {visibleTip.docsSection ? ( -
-
- - ) : null} - + ) : 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/index.css b/apps/web/src/index.css index ac3e8687..5cb88bf7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1159,4 +1159,5 @@ html[data-hidden] .animate-sidebar-nav-pulse { 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/tips/tips.ts b/apps/web/src/lib/tips/tips.ts index 6a31d422..d2fbe1c7 100644 --- a/apps/web/src/lib/tips/tips.ts +++ b/apps/web/src/lib/tips/tips.ts @@ -29,7 +29,7 @@ export const tips: Tip[] = [ { id: "brain", title: "Brain", - body: "Repo-scoped shared memory that persists across agent sessions. Store objects, lists, and event logs your agents can access.", + 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"], @@ -50,6 +50,38 @@ export const tips: Tip[] = [ 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 { 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/docs/superpowers/plans/2026-06-11-guided-tips.md b/docs/superpowers/plans/2026-06-11-guided-tips.md deleted file mode 100644 index 37cbd58a..00000000 --- a/docs/superpowers/plans/2026-06-11-guided-tips.md +++ /dev/null @@ -1,1357 +0,0 @@ -# Guided Tips Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a feature discovery system with two surfaces — inline popovers on new features and an ambient tip bar — backed by a shared tip registry and Jotai state. - -**Architecture:** A unified tip registry (`tips.ts`) defines all tips. Jotai atoms (`atomWithLocalStorage`) track dismissal state and the user's last-seen version. Two independent UI surfaces consume this shared state: `TipSpot` (a wrapper component that renders a Radix Popover anchored to its child) and `AmbientTipBar` (an idle-triggered bottom bar). A `TipQueueProvider` context coordinates inline popovers so only one shows at a time. - -**Tech Stack:** React, Jotai, Radix UI Popover, Tailwind CSS, Vitest - -**Spec:** `docs/superpowers/specs/2026-06-11-guided-tips-design.md` - ---- - -## File Map - -| File | Responsibility | -| ------------------------------------------------------- | -------------------------------------------------------------------------- | -| `apps/web/src/lib/tips/tips.ts` | Tip definitions array and `Tip` type | -| `apps/web/src/lib/tips/tips-state.ts` | Jotai atoms: `tipsEnabledAtom`, `dismissedTipsAtom`, `lastSeenVersionAtom` | -| `apps/web/src/lib/tips/use-tip.ts` | `useTip(id)` hook — reads registry + state, returns visibility and actions | -| `apps/web/src/lib/tips/version-compare.ts` | Simple semver comparison (`isVersionNewer`) | -| `apps/web/src/components/tips/tip-popover-content.tsx` | Popover body: title, description, learn more, dismiss, don't show | -| `apps/web/src/components/tips/tip-spot.tsx` | `` wrapper — renders Radix Popover around its child | -| `apps/web/src/components/tips/tip-queue-provider.tsx` | Context provider: ensures one inline popover at a time | -| `apps/web/src/components/tips/ambient-tip-bar.tsx` | Bottom bar: idle detection, random roll, auto-hide timer, hover pause | -| `apps/web/src/lib/store.ts` | Modified — export new tip atoms | -| `apps/web/src/App.tsx` | Modified — wrap layout in `TipQueueProvider` | -| `apps/web/src/components/app/agents-view.tsx` | Modified — add `AmbientTipBar` in bottom buffer area | -| `apps/web/src/components/app/notification-settings.tsx` | Modified — add Tips section with toggle and reset button | - ---- - -### Task 1: Version Comparison Utility - -**Files:** - -- Create: `apps/web/src/lib/tips/version-compare.ts` -- Test: `apps/web/src/lib/tips/__tests__/version-compare.test.ts` - -- [ ] **Step 1: Write the failing tests** - -Create `apps/web/src/lib/tips/__tests__/version-compare.test.ts`: - -```ts -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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/version-compare.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -Create `apps/web/src/lib/tips/version-compare.ts`: - -```ts -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; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/version-compare.test.ts` -Expected: All 7 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/tips/version-compare.ts apps/web/src/lib/tips/__tests__/version-compare.test.ts -git commit -m "feat(tips): add semver comparison utility" -``` - ---- - -### Task 2: Tip Registry - -**Files:** - -- Create: `apps/web/src/lib/tips/tips.ts` -- Test: `apps/web/src/lib/tips/__tests__/tips.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `apps/web/src/lib/tips/__tests__/tips.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; - -import { type Surface, type Tip, 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); - } - } - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -Create `apps/web/src/lib/tips/tips.ts`: - -```ts -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: "shortcuts", - 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 access.", - docsSection: "events", - 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"], - }, -]; - -export function getTipById(id: string): Tip | undefined { - return tips.find((t) => t.id === id); -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips.test.ts` -Expected: All 3 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/tips/tips.ts apps/web/src/lib/tips/__tests__/tips.test.ts -git commit -m "feat(tips): add tip registry with starter tips" -``` - ---- - -### Task 3: Tips State Atoms - -**Files:** - -- Create: `apps/web/src/lib/tips/tips-state.ts` -- Test: `apps/web/src/lib/tips/__tests__/tips-state.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `apps/web/src/lib/tips/__tests__/tips-state.test.ts`: - -```ts -import { createStore } from "jotai"; -import { beforeEach, describe, expect, it } from "vitest"; - -import { - dismissedTipsAtom, - lastSeenVersionAtom, - tipsEnabledAtom, -} from "../tips-state"; - -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"] - ); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips-state.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -Create `apps/web/src/lib/tips/tips-state.ts`: - -```ts -import { atomWithLocalStorage } from "../store"; - -export const tipsEnabledAtom = atomWithLocalStorage( - "dispatch:tipsEnabled", - true -); - -export const dismissedTipsAtom = atomWithLocalStorage( - "dispatch:dismissedTips", - [] -); - -export const lastSeenVersionAtom = atomWithLocalStorage( - "dispatch:lastSeenVersion", - null -); -``` - -**Important:** `atomWithLocalStorage` is currently not exported from `apps/web/src/lib/store.ts`. Add the export: - -In `apps/web/src/lib/store.ts`, change the function declaration from: - -```ts -function atomWithLocalStorage(key: string, initialValue: T) { -``` - -to: - -```ts -export function atomWithLocalStorage(key: string, initialValue: T) { -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/tips-state.test.ts` -Expected: All 5 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/tips/tips-state.ts apps/web/src/lib/tips/__tests__/tips-state.test.ts apps/web/src/lib/store.ts -git commit -m "feat(tips): add Jotai atoms for tips state" -``` - ---- - -### Task 4: useTip Hook - -**Files:** - -- Create: `apps/web/src/lib/tips/use-tip.ts` -- Test: `apps/web/src/lib/tips/__tests__/use-tip.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `apps/web/src/lib/tips/__tests__/use-tip.test.ts`: - -```ts -import { createStore, Provider } from "jotai"; -import { renderHook } from "@testing-library/react"; -import { act } from "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); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/use-tip.test.ts` -Expected: FAIL — module not found - -- [ ] **Step 3: Write implementation** - -Create `apps/web/src/lib/tips/use-tip.ts`: - -```ts -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 }; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run apps/web/src/lib/tips/__tests__/use-tip.test.ts` -Expected: All 8 tests PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/tips/use-tip.ts apps/web/src/lib/tips/__tests__/use-tip.test.ts -git commit -m "feat(tips): add useTip hook with version gating" -``` - ---- - -### Task 5: TipQueueProvider - -**Files:** - -- Create: `apps/web/src/components/tips/tip-queue-provider.tsx` - -- [ ] **Step 1: Write the provider** - -Create `apps/web/src/components/tips/tip-queue-provider.tsx`: - -```tsx -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} - - ); -} -``` - -- [ ] **Step 2: Wire into App.tsx** - -In `apps/web/src/App.tsx`, import the provider: - -```ts -import { TipQueueProvider } from "@/components/tips/tip-queue-provider"; -``` - -Wrap the return value in `DashboardLayout` (around line 242): - -```tsx -return ( - - - - - - -); -``` - -- [ ] **Step 3: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/tips/tip-queue-provider.tsx apps/web/src/App.tsx -git commit -m "feat(tips): add TipQueueProvider for single-popover coordination" -``` - ---- - -### Task 6: TipPopoverContent Component - -**Files:** - -- Create: `apps/web/src/components/tips/tip-popover-content.tsx` - -- [ ] **Step 1: Write the component** - -Create `apps/web/src/components/tips/tip-popover-content.tsx`: - -```tsx -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 ? ( - - ) : ( - - )} - -
-
- ); -} -``` - -- [ ] **Step 2: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/tips/tip-popover-content.tsx -git commit -m "feat(tips): add TipPopoverContent component" -``` - ---- - -### Task 7: TipSpot Wrapper Component - -**Files:** - -- Create: `apps/web/src/components/tips/tip-spot.tsx` - -- [ ] **Step 1: Write the component** - -Create `apps/web/src/components/tips/tip-spot.tsx`: - -```tsx -import { useCallback, useEffect, useRef, useState } from "react"; - -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"; - -type TipSpotProps = { - tipId: string; - side?: "top" | "bottom" | "left" | "right"; - align?: "start" | "center" | "end"; - sideOffset?: number; - onOpenDocs?: (section: string) => void; - children: React.ReactNode; -}; - -export function TipSpot({ - tipId, - side = "right", - align = "center", - sideOffset = 8, - onOpenDocs, - children, -}: TipSpotProps) { - const { tip, shouldShowInline, dismiss, disableAll } = useTip(tipId); - const { requestOpen, release } = useTipQueue(); - const [open, setOpen] = useState(false); - const mountedRef = useRef(false); - - useEffect(() => { - if (!shouldShowInline || mountedRef.current) return; - mountedRef.current = true; - - const timer = setTimeout(() => { - if (requestOpen(tipId)) { - setOpen(true); - } - }, 500); - - return () => clearTimeout(timer); - }, [shouldShowInline, tipId, requestOpen]); - - 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) { - handleDismiss(); - } - }, - [handleDismiss] - ); - - if (!tip || !shouldShowInline) { - return <>{children}; - } - - return ( - - {children} - e.preventDefault()} - > - - - - ); -} -``` - -- [ ] **Step 2: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/tips/tip-spot.tsx -git commit -m "feat(tips): add TipSpot wrapper component" -``` - ---- - -### Task 8: AmbientTipBar Component - -**Files:** - -- Create: `apps/web/src/components/tips/ambient-tip-bar.tsx` - -- [ ] **Step 1: Write the component** - -Create `apps/web/src/components/tips/ambient-tip-bar.tsx`: - -```tsx -import { AnimatePresence, motion } from "framer-motion"; -import { Lightbulb, X } from "lucide-react"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { tips, type Tip } from "@/lib/tips/tips"; -import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; - -const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes -const SHOW_CHANCE = 0.4; -const AUTO_HIDE_MS = 30_000; - -export function AmbientTipBar({ - onOpenDocs, -}: { - onOpenDocs?: (section: string) => void; -}) { - const enabled = useAtomValue(tipsEnabledAtom); - const dismissed = useAtomValue(dismissedTipsAtom); - const setDismissed = useSetAtom(dismissedTipsAtom); - const setEnabled = useSetAtom(tipsEnabledAtom); - - const [visibleTip, setVisibleTip] = useState(null); - const shownThisSessionRef = useRef(new Set()); - const hoveredRef = useRef(false); - const autoHideTimerRef = useRef>(); - const idleTimerRef = 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]); - - useEffect(() => { - if (!enabled) { - setVisibleTip(null); - return; - } - - const onActivity = () => { - if (visibleTip) return; - 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); - clearTimeout(autoHideTimerRef.current); - }, [setEnabled]); - - return ( - - {visibleTip ? ( - -
-
- - - - {visibleTip.title} - - - {visibleTip.body} - - {visibleTip.docsSection ? ( - - ) : null} -
-
- - -
-
-
- ) : null} -
- ); -} -``` - -- [ ] **Step 2: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/tips/ambient-tip-bar.tsx -git commit -m "feat(tips): add AmbientTipBar with idle detection and auto-hide" -``` - ---- - -### Task 9: Wire AmbientTipBar into AgentsView - -**Files:** - -- Modify: `apps/web/src/components/app/agents-view.tsx` - -The ambient tip bar goes in the bottom buffer area of the terminal pane. The terminal content div at line 579 has `pb-14` (56px bottom padding) — this is the buffer zone. - -- [ ] **Step 1: Add import** - -At the top of `apps/web/src/components/app/agents-view.tsx`, add: - -```ts -import { AmbientTipBar } from "@/components/tips/ambient-tip-bar"; -``` - -- [ ] **Step 2: Add the tip bar** - -Find the `pb-14` div (line 579): - -```tsx -
-``` - -After the `` closing tag (line 627) and before the closing `
` of the `pb-14` container, add the ambient tip bar absolutely positioned in the bottom padding zone: - -```tsx -
- -
-``` - -The full section after the edit (lines ~627-630): - -```tsx - /> -
- -
-
-``` - -This positions the bar inside the 56px (`pb-14`) bottom buffer without affecting terminal layout. - -- [ ] **Step 3: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/app/agents-view.tsx -git commit -m "feat(tips): wire AmbientTipBar into agents view bottom buffer" -``` - ---- - -### Task 10: Wire lastSeenVersion Initialization - -**Files:** - -- Modify: `apps/web/src/App.tsx` - -The `lastSeenVersionAtom` needs to be read on app load, compared against `BUILD_VERSION`, and then updated. This should happen once per session. - -- [ ] **Step 1: Create version initializer** - -Add a small component in `apps/web/src/App.tsx` (above `DashboardLayout`): - -```tsx -import { useAtom } from "jotai"; -import { useEffect, useRef } from "react"; - -import { BUILD_VERSION } from "@/lib/version"; -import { lastSeenVersionAtom } from "@/lib/tips/tips-state"; - -function TipsVersionInit() { - const [lastSeen, setLastSeen] = useAtom(lastSeenVersionAtom); - const didInit = useRef(false); - - useEffect(() => { - if (didInit.current) return; - didInit.current = true; - if (lastSeen !== BUILD_VERSION) { - // Delay the update so TipSpot components can read the old - // lastSeenVersion during this render cycle and decide which - // inline tips to show. - const timer = setTimeout(() => setLastSeen(BUILD_VERSION), 2000); - return () => clearTimeout(timer); - } - }, [lastSeen, setLastSeen]); - - return null; -} -``` - -- [ ] **Step 2: Render inside DashboardLayout** - -In the `DashboardLayout` return (around line 242), add `` inside the `TipQueueProvider`: - -```tsx -return ( - - - - - - - -); -``` - -- [ ] **Step 3: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/App.tsx -git commit -m "feat(tips): initialize lastSeenVersion on app load" -``` - ---- - -### Task 11: Settings Integration - -**Files:** - -- Modify: `apps/web/src/components/app/notification-settings.tsx` - -- [ ] **Step 1: Add TipsSection component** - -At the top of `notification-settings.tsx`, add imports: - -```ts -import { useAtom, useSetAtom } from "jotai"; -import { RotateCcw } from "lucide-react"; -import { toast } from "sonner"; - -import { dismissedTipsAtom, tipsEnabledAtom } from "@/lib/tips/tips-state"; -``` - -After the `SoundCuesSection` component (line 92), add: - -```tsx -function TipsSection(): JSX.Element { - const [enabled, setEnabled] = useAtom(tipsEnabledAtom); - const setDismissed = useSetAtom(dismissedTipsAtom); - - return ( -
-

- Tips & Guidance -

-

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

-
- - -
-
- ); -} -``` - -- [ ] **Step 2: Render TipsSection** - -Find where `` is rendered inside `NotificationSettings`. It should be near the top of the component's JSX. Add `` right after it, with a border separator: - -```tsx - - -
- -
-``` - -- [ ] **Step 3: Verify existing imports** - -Check that `Checkbox`, `Button`, `toast` are already imported in the file. If not, add them. `Checkbox` and `Button` are likely already imported (used by SoundCuesSection). `toast` from `sonner` may need to be added. - -- [ ] **Step 4: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/components/app/notification-settings.tsx -git commit -m "feat(tips): add tips toggle and reset button to notification settings" -``` - ---- - -### Task 12: Add a TipSpot to an Existing Feature - -**Files:** - -- Modify: `apps/web/src/components/app/agents-view.tsx` (QuickPhrasesButton area) - -Wire up one `TipSpot` to validate the full inline flow end-to-end. - -- [ ] **Step 1: Wrap QuickPhrasesButton with TipSpot** - -In `apps/web/src/components/app/agents-view.tsx`, import TipSpot: - -```ts -import { TipSpot } from "@/components/tips/tip-spot"; -``` - -Find the `` usage (around line 570): - -```tsx - -``` - -Wrap it: - -```tsx - - - -``` - -- [ ] **Step 2: Run type check** - -Run: `pnpm run check` -Expected: No type errors - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/app/agents-view.tsx -git commit -m "feat(tips): add TipSpot to QuickPhrasesButton" -``` - ---- - -### Task 13: Finalize and Validate - -- [ ] **Step 1: Run full type check** - -Run: `pnpm run check` -Expected: No type errors across the workspace - -- [ ] **Step 2: Run web finalization** - -Run: `pnpm run finalize:web` -Expected: Type check and production build succeed - -- [ ] **Step 3: Run unit tests** - -Run: `pnpm vitest run apps/web/src/lib/tips/` -Expected: All tests pass - -- [ ] **Step 4: Run E2E tests** - -Run: `pnpm run test:e2e` -Expected: All existing tests pass (no regressions) - -- [ ] **Step 5: Visual validation** - -Start a dev server using `repo_dev_up` and validate in Playwright: - -1. Open the app -2. Set `dispatch:lastSeenVersion` in localStorage to a version older than "0.23.0" -3. Reload — verify the Quick Phrases TipSpot popover appears -4. Dismiss it — verify it doesn't reappear on reload -5. Go to Settings > Notifications — verify Tips section with toggle and reset button -6. Click "Reset dismissed tips" — verify toast appears -7. Reload — verify the tip can appear again -8. Toggle "Show tips" off — verify no tips appear - -- [ ] **Step 6: Commit any fixes** - -```bash -git add -A -git commit -m "fix(tips): address validation feedback" -``` diff --git a/docs/superpowers/specs/2026-06-11-guided-tips-design.md b/docs/superpowers/specs/2026-06-11-guided-tips-design.md deleted file mode 100644 index 72c30e51..00000000 --- a/docs/superpowers/specs/2026-06-11-guided-tips-design.md +++ /dev/null @@ -1,182 +0,0 @@ -# Guided Tips — Feature Discovery System - -A lightweight feature discovery system for Dispatch with two surfaces: inline popovers anchored to new features after an upgrade, and an ambient tip bar in the bottom buffer area that shows tips during idle periods. - -## Audience - -Power users who self-host Dispatch. The system should be respectful of attention, easy to dismiss, and not feel patronizing. - -## Architecture - -### Unified Tip Registry - -A single `tips.ts` file holds all tip definitions as a typed array. Each tip declares: - -```ts -type Tip = { - id: string; // unique, stable identifier (e.g., "quick-phrases") - title: string; // short heading (e.g., "Quick Phrases") - body: string; // 1-2 sentence description - docsPath?: string; // path to in-app docs (renders "Learn more" link) - since: string; // semver version this feature shipped in (e.g., "0.23.0") - surfaces: Surface[]; // which surfaces this tip is eligible for -}; - -type Surface = "inline" | "ambient"; -``` - -- `surfaces` controls where a tip can appear. Most tips are `["inline", "ambient"]`. Tips without a specific UI anchor (general workflow tips) use `["ambient"]` only. -- `since` enables version-gating for inline tips: only show if the user upgraded past this version. - -Adding a new tip is a single entry in this registry. - -### State Management - -Three Jotai atoms persisted to localStorage: - -**`tipsEnabledAtom`** — `atomWithLocalStorage("dispatch:tipsEnabled", true)` -Master toggle. When false, `TipSpot` renders children transparently, ambient bar shows nothing. Controlled from both surfaces' "Don't show tips" links and from Settings. - -**`dismissedTipsAtom`** — `atomWithLocalStorage("dispatch:dismissedTips", [])` -Array of dismissed tip IDs. Shared across both surfaces — dismissing a tip from either surface adds its ID here. The Settings reset button clears this array. - -**`lastSeenVersionAtom`** — `atomWithLocalStorage("dispatch:lastSeenVersion", null)` -The app version from the user's previous session. On app load, tips with `since` between `lastSeenVersion` and the current version are candidates for inline display. After comparison, update to the current version. First-time users (`null`) get no inline tips — they haven't upgraded, so nothing is "new." They discover features through the ambient bar over time. - -### Shared Hook - -`useTip(id: string)` is the hook both surfaces use. It reads from the registry and state atoms, returning: - -- `tip` — the tip definition (or null if ID not found) -- `shouldShowInline` — true if the tip is undismissed, tips are enabled, and the tip's version is newer than `lastSeenVersion` -- `shouldShowAmbient` — true if the tip is undismissed, tips are enabled (no version gating for ambient) -- `dismiss()` — adds the tip ID to `dismissedTipsAtom` -- `disableAll()` — sets `tipsEnabledAtom` to false - -## Surface 1: Inline Popover (TipSpot) - -### Usage - -Wrap the target element with a `TipSpot` component at the usage site: - -```tsx - - - -``` - -When the tip should not show (already dismissed, tips disabled, version not applicable), `TipSpot` renders its child transparently with zero overhead. - -### Behavior - -- **Auto-open on mount** after a ~500ms delay so the UI has settled. -- **One at a time** — a `TipQueueProvider` context ensures only one popover is open. First eligible tip in mount order wins; others wait until it's dismissed. -- **No sequential tour** — tips are independent. No step counter, no "next" button. If another tip is queued, it appears after the current one is dismissed. - -### Popover Content - -- Title (bold) with a small accent icon (sparkle or similar) -- Body text (1-2 lines) -- "Learn more →" link to docs (if `docsPath` is set) -- Dismiss `✕` button (top right) -- "Don't show tips" link (bottom right) — disables the entire system - -### Dismissal - -- Clicking `✕`, clicking outside the popover, or pressing Escape all permanently dismiss the tip. -- "Don't show tips" sets `tipsEnabledAtom` to false, hiding all tips everywhere. - -### Styling - -Uses the existing Radix Popover component. Glass overlay aesthetic with a subtle purple accent border to distinguish from regular popovers. Fade + scale entrance animation matching the existing popover pattern (zoom-in-95, ~200ms, ease-out). - -## Surface 2: Ambient Tip Bar - -### Placement - -The existing bottom buffer zone below the terminal area. A single subtle line of text. - -### Idle Detection & Display Logic - -1. An inactivity timer starts when there is no keyboard/mouse activity. Agent activity is not considered — tips can appear while an agent is running, since that's often when users are idle and waiting. -2. After a base delay (~2-3 minutes of inactivity), a random roll determines whether to show a tip (~40% chance). -3. If the roll fails, reset and wait another interval. -4. If the roll succeeds, pick a random undismissed tip eligible for the ambient surface. Avoid repeating the same tip within the same browser session (tracked in memory, not localStorage). -5. The tip fades in gently. - -### Auto-Hide Timer - -Once visible, a 30-second countdown begins: - -- If the user hovers over the tip bar, the countdown pauses. -- When the user moves away, the countdown resumes. -- When the countdown expires, the tip fades out quietly. -- The tip is **not** dismissed on auto-hide — it returns to the pool and could appear in a future idle period. -- Only clicking `✕` permanently dismisses the tip. - -### Content Layout - -A single line: - -``` -💡 Personas — Launch specialized review agents with structured feedback. Learn more → · Don't show tips ✕ -``` - -- Lightbulb icon (subtle opacity) -- Feature name (slightly brighter weight) -- Short description -- "Learn more →" link to docs -- "Don't show tips" link — disables the entire system -- Dismiss `✕` — permanently dismisses this specific tip - -### Styling - -Subtle top border separator. Very low-contrast background (nearly transparent). Text at reduced opacity. The bar should feel ambient, not attention-grabbing. - -## Settings Integration - -In the existing Notifications settings section, add: - -- **"Show tips" toggle** — controls `tipsEnabledAtom`. When off, no inline popovers auto-open and the ambient bar never shows tips. -- **"Reset dismissed tips" button** — clears the `dismissedTipsAtom` array. Shows a confirmation toast ("Tips reset — you'll see them again as you use the app"). - -## File Structure - -``` -apps/web/src/ -├── lib/ -│ └── tips/ -│ ├── tips.ts # tip registry (array of tip definitions) -│ ├── tips-state.ts # Jotai atoms: tipsEnabled, dismissedTips, lastSeenVersion -│ └── use-tip.ts # hook: useTip(id) → { tip, shouldShowInline, shouldShowAmbient, dismiss, disableAll } -├── components/ -│ └── tips/ -│ ├── tip-spot.tsx # wrapper component (inline popover) -│ ├── tip-popover.tsx # popover content (title, body, learn more, dismiss, don't show) -│ ├── ambient-tip-bar.tsx # bottom bar with idle detection + auto-hide timer -│ └── tip-queue-provider.tsx # context that ensures one inline popover at a time -``` - -- `TipQueueProvider` wraps the app layout and coordinates which `TipSpot` gets to open its popover. -- `AmbientTipBar` lives in the layout near the bottom buffer. It handles idle detection, random roll, hover-pause timer, and tip selection. -- Settings controls go in the existing notifications settings component. - -## Testing Strategy - -- **Unit tests** for `useTip` hook: version comparison logic, dismissal state, enable/disable. -- **Unit tests** for idle detection and timer logic in the ambient bar (mock timers). -- **E2E test** for inline popover: wrap a test element with `TipSpot`, verify popover appears and can be dismissed. -- **E2E test** for ambient bar: trigger idle conditions, verify tip appears and auto-hides. -- **E2E test** for settings: toggle tips off, verify neither surface shows. Reset tips, verify they reappear. - -## Starter Tips - -The initial registry should ship with a handful of tips for existing features to validate the system: - -- Quick Phrases -- Personas -- Brain (shared memory) -- Job scheduler -- Media sidebar - -These all have existing docs pages and clear UI anchors. New tips get added as part of the feature development process — one registry entry plus a `TipSpot` wrapper at the usage site. From 7c14eb76ba13e21a71cade66349aa02dd1fc0889 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 12 Jun 2026 21:28:35 -0600 Subject: [PATCH 28/28] Address review feedback: accessibility, pointer-events, extract TipsVersionInit - Add aria-labels to lightbulb and Learn more buttons (a11y) - Add pointer-events-none to tip bar wrapper, pointer-events-auto on interactive children - Throttle mousemove idle timer resets (10s debounce) - Add Escape key handler to dismiss TipSpot popovers - Extract TipsVersionInit from App.tsx into tips/tips-version-init.tsx Co-Authored-By: Claude Opus 4.6 --- apps/web/src/App.tsx | 20 +----------------- apps/web/src/components/app/agents-view.tsx | 2 +- .../src/components/tips/ambient-tip-bar.tsx | 18 ++++++++++------ apps/web/src/components/tips/tip-spot.tsx | 9 ++++++++ .../src/components/tips/tips-version-init.tsx | 21 +++++++++++++++++++ 5 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/tips/tips-version-init.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5f02aa24..3bb8aec0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useAtom } from "jotai"; import { Outlet, useMatches, @@ -30,8 +29,7 @@ import { import { type IdeType, sanitizeEnabledIdes } from "@/lib/ide-types"; import { sortAgentsByCreatedAtDesc } from "@/lib/agent-sort"; import { TipQueueProvider } from "@/components/tips/tip-queue-provider"; -import { BUILD_VERSION } from "@/lib/version"; -import { lastSeenVersionAtom } from "@/lib/tips/tips-state"; +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"; @@ -76,22 +74,6 @@ export function useDashboardContext(): DashboardContextValue { return useOutletContext(); } -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; -} - export function DashboardLayout(): JSX.Element { const navigate = useNavigate(); const matches = useMatches() as Array<{ handle?: RouteHandle }>; diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 4c6b39e5..e26e7e5b 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -630,7 +630,7 @@ export function AgentsView({ } /> {!isMobile ? ( -
+
) : null} diff --git a/apps/web/src/components/tips/ambient-tip-bar.tsx b/apps/web/src/components/tips/ambient-tip-bar.tsx index d4372fa5..0d067262 100644 --- a/apps/web/src/components/tips/ambient-tip-bar.tsx +++ b/apps/web/src/components/tips/ambient-tip-bar.tsx @@ -66,6 +66,8 @@ export function AmbientTipBar() { }, IDLE_DELAY_MS); }, [enabled, visibleTip, getEligibleTip, startAutoHide]); + const lastResetRef = useRef(0); + useEffect(() => { if (!enabled) { setVisibleTip(null); @@ -74,6 +76,9 @@ export function AmbientTipBar() { const onActivity = () => { if (visibleTip) return; + const now = Date.now(); + if (now - lastResetRef.current < 10_000) return; + lastResetRef.current = now; resetIdleTimer(); }; @@ -155,9 +160,9 @@ export function AmbientTipBar() { {/* 1: lightbulb — always in the same spot */} @@ -224,13 +230,13 @@ export function AmbientTipBar() { > @@ -246,7 +252,7 @@ export function AmbientTipBar() { > diff --git a/apps/web/src/components/tips/tip-spot.tsx b/apps/web/src/components/tips/tip-spot.tsx index 15c04d03..05f68f87 100644 --- a/apps/web/src/components/tips/tip-spot.tsx +++ b/apps/web/src/components/tips/tip-spot.tsx @@ -157,6 +157,15 @@ export function TipSpot({ [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(); 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; +}