diff --git a/ui/src/components/AnnouncementWell.tsx b/ui/src/components/AnnouncementWell.tsx index c6212de9fb7..2eb996dd33c 100644 --- a/ui/src/components/AnnouncementWell.tsx +++ b/ui/src/components/AnnouncementWell.tsx @@ -14,16 +14,16 @@ import { AnnouncementCard } from "./AnnouncementCard"; // Includes the command palette, sheets and dialogs created outside DialogContext. export const MODAL_SELECTOR = '[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [aria-modal="true"]:not([data-state="closed"]), dialog[open]'; -export function useModalOpen(enabled: boolean) { +export function useModalOpen(enabled: boolean, selector = MODAL_SELECTOR) { const [open, setOpen] = useState(false); useEffect(() => { if (!enabled) return; - const scan = () => setOpen(Boolean(document.querySelector(MODAL_SELECTOR))); + const scan = () => setOpen(Boolean(document.querySelector(selector))); scan(); const observer = new MutationObserver(scan); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state", "aria-modal", "open"] }); return () => observer.disconnect(); - }, [enabled]); + }, [enabled, selector]); return open; } diff --git a/ui/src/components/GitHubStarPrompt.test.tsx b/ui/src/components/GitHubStarPrompt.test.tsx index 8da3c90fa54..223f4a849f2 100644 --- a/ui/src/components/GitHubStarPrompt.test.tsx +++ b/ui/src/components/GitHubStarPrompt.test.tsx @@ -2,6 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useNavigate, type NavigateFunction } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; import type { HealthStatus } from "@/api/health"; @@ -22,10 +23,14 @@ vi.mock("@/api/issues", () => ({ issuesApi: { describe("GitHub star prompt", () => { let container: HTMLDivElement; let root: Root; - const render = async (health: Partial | null = { deploymentMode: "local_trusted" }) => { + let navigate: NavigateFunction | null = null; + function NavigateHandle() { navigate = useNavigate(); return null; } + const render = async (health: Partial | null = { deploymentMode: "local_trusted" }, path = "/CHA/dashboard") => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); if (health) client.setQueryData(["health"], health); - await act(async () => root.render()); + await act(async () => root.render( + + )); // Let the onboarding and done-count queries settle, however slow the run. for (let idle = 0, tick = 0; idle < 2 && tick < 100; tick++) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); @@ -65,6 +70,51 @@ describe("GitHub star prompt", () => { expect(visible()).toBe(true); }); + it("never covers a task's Retry: it waits until no Retry banner is on screen", async () => { + const banner = document.createElement("div"); + banner.setAttribute("role", "status"); banner.setAttribute("aria-label", "Task status"); + banner.innerHTML = 'Couldn’t finish this attempt. Your message is saved.'; + document.body.append(banner); + try { + await render(); + expect(visible()).toBe(false); + expect(banner.closest('[aria-hidden="true"]')).toBeNull(); + await act(async () => { banner.remove(); }); + expect(visible()).toBe(true); + } finally { banner.remove(); } + }); + + it("does not interrupt while the user is looking at a task or a chat", async () => { + await render(undefined, "/CHA/issues/CHA-4"); + expect(visible()).toBe(false); + await act(async () => { navigate!("/CHA/chats/claude"); }); + expect(visible()).toBe(false); + expect(window.localStorage.getItem(STAR_PROMPT_SEEN_KEY)).toBeNull(); + await act(async () => { navigate!("/CHA/issues"); }); + expect(visible()).toBe(true); + }); + + it("closes when the user goes back to a task, or a task asks for Retry", async () => { + await render(); + expect(visible()).toBe(true); + await act(async () => { navigate!("/CHA/issues/CHA-4"); }); + expect(visible()).toBe(false); + + window.localStorage.clear(); + await act(async () => root.unmount()); + root = createRoot(container); + await render(); + expect(visible()).toBe(true); + const retry = document.createElement("button"); + retry.setAttribute("data-testid", "task-execution-retry"); + document.body.append(retry); + try { + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); + expect(visible()).toBe(false); + expect(retry.closest('[aria-hidden="true"]')).toBeNull(); + } finally { retry.remove(); } + }); + it("does not open after another tab has shown it", async () => { const other = document.createElement("div"); other.setAttribute("role", "dialog"); other.setAttribute("data-state", "open"); diff --git a/ui/src/components/GitHubStarPrompt.tsx b/ui/src/components/GitHubStarPrompt.tsx index 598804a29d8..70edef31f5d 100644 --- a/ui/src/components/GitHubStarPrompt.tsx +++ b/ui/src/components/GitHubStarPrompt.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useCompany } from "@/context/CompanyContext"; import { useDialogState } from "@/context/DialogContext"; +import { useLocation } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { MODAL_SELECTOR, useModalOpen, useOnboardingInProgress } from "./AnnouncementWell"; import { PaperclipMark } from "./PaperclipLockup"; @@ -14,6 +15,15 @@ import { PaperclipMark } from "./PaperclipLockup"; export const STAR_PROMPT_COMPLETED_TASKS = 5; export const STAR_PROMPT_SEEN_KEY = "paperclip.github-star-prompt.seen"; export const PAPERCLIP_GITHUB_URL = "https://github.com/paperclipai/paperclip"; +export const TASK_ATTENTION_SELECTOR = '[data-testid="task-execution-retry"], [role="status"][aria-label="Task status"]'; +const TASK_LIST_VIEWS = new Set(["all", "active", "backlog", "done", "recent"]); + +export function isTaskFocusPath(pathname: string) { + const segments = pathname.split("/").filter(Boolean); + const [section, ref] = segments.slice(-2); + return (segments.length === 2 || segments.length === 3) && Boolean(ref) + && (section === "chats" || (section === "issues" && !TASK_LIST_VIEWS.has(ref!))); +} function readSeen() { try { return window.localStorage.getItem(STAR_PROMPT_SEEN_KEY) !== null; } catch { return true; } @@ -48,6 +58,8 @@ export function GitHubStarPrompt() { }); const reached = eligible && done.isSuccess && done.data.length >= STAR_PROMPT_COMPLETED_TASKS; const otherModalOpen = useModalOpen(reached); + const taskNeedsAttention = useModalOpen(reached || open, TASK_ATTENTION_SELECTOR); + const midTask = isTaskFocusPath(useLocation().pathname); useEffect(() => { if (seen) return; @@ -59,13 +71,18 @@ export function GitHubStarPrompt() { }, [seen]); useEffect(() => { - if (!reached || otherModalOpen || document.querySelector(MODAL_SELECTOR)) return; + if (!reached || otherModalOpen || taskNeedsAttention || midTask + || document.querySelector(`${MODAL_SELECTOR}, ${TASK_ATTENTION_SELECTOR}`)) return; // Another tab may have shown it after this one mounted. if (readSeen()) { setSeen(true); return; } markSeen(); setSeen(true); setOpen(true); - }, [reached, otherModalOpen]); + }, [reached, otherModalOpen, taskNeedsAttention, midTask]); + + useEffect(() => { + if (open && (midTask || taskNeedsAttention)) setOpen(false); + }, [open, midTask, taskNeedsAttention]); return (