From 962b97ce841793e6ba99ffb7255aaad3b10f37c1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 17:11:16 -0700 Subject: [PATCH] FN-5967: add first-task GitHub star prompt Show a one-time GitHub star prompt after a task first reaches done. - detect task status transitions into done and trigger the prompt only in project view - add a dismissible GitHub star prompt component plus localStorage-backed persistence hook - cover the new prompt behavior with component, hook, and transition helper tests - document the prompt behavior and styling guidance in the dashboard guide Files changed: docs/dashboard-guide.md | 3 + packages/dashboard/app/App.tsx | 21 +++++- .../dashboard/app/components/GitHubStarPrompt.css | 76 ++++++++++++++++++++++ .../dashboard/app/components/GitHubStarPrompt.tsx | 53 +++++++++++++++ .../app/components/__tests__/App.test.tsx | 12 +++- .../components/__tests__/GitHubStarPrompt.test.tsx | 40 ++++++++++++ .../hooks/__tests__/useGitHubStarPrompt.test.ts | 64 ++++++++++++++++++ .../dashboard/app/hooks/useGitHubStarPrompt.ts | 46 +++++++++++++ 8 files changed, 313 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-5967 Fusion-Task-Lineage: 5fffdf4d-61d8-4ac8-bcf4-8b453b639b28 --- docs/dashboard-guide.md | 3 + packages/dashboard/app/App.tsx | 21 ++++- .../app/components/GitHubStarPrompt.css | 76 +++++++++++++++++++ .../app/components/GitHubStarPrompt.tsx | 53 +++++++++++++ .../app/components/__tests__/App.test.tsx | 12 ++- .../__tests__/GitHubStarPrompt.test.tsx | 40 ++++++++++ .../__tests__/useGitHubStarPrompt.test.ts | 64 ++++++++++++++++ .../app/hooks/useGitHubStarPrompt.ts | 46 +++++++++++ 8 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 packages/dashboard/app/components/GitHubStarPrompt.css create mode 100644 packages/dashboard/app/components/GitHubStarPrompt.tsx create mode 100644 packages/dashboard/app/components/__tests__/GitHubStarPrompt.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts create mode 100644 packages/dashboard/app/hooks/useGitHubStarPrompt.ts diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 82f80c2897..bb26119f90 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -214,6 +214,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. - mailbox entry points now show unread/pending indicators: the desktop Header mailbox toggle shows a pending-approval dot first or an unread dot when unread mail exists without pending approvals, while Header overflow + Mobile mailbox entry points continue to surface mailbox badges/dots - approval lifecycle SSE events (`approval:requested`, `approval:updated`, `approval:decided`) trigger mailbox approvals refresh without manual reload - when a task newly enters `awaiting-approval`, the app shows a persistent approval banner above project content with an **Open Mailbox** CTA; dismissals are remembered per approval item until that item advances or a different one arrives +- when a task first transitions into `done`, the dashboard shows a one-time **Enjoying Fusion?** GitHub star prompt in the project view; clicking **Star on GitHub** or dismissing the card marks it shown in browser `localStorage`, so it does not reappear on reload or later task completions - Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links - Separate top-level messages from the same sender remain independent in the inbox and detail pane @@ -1098,6 +1099,8 @@ Reuse existing primitives from `styles.css`: Don't create parallel button/form variants — add states (`:hover`, `:focus-visible`, `:active`) to the existing primitives. +Small fixed notification cards (for example the first-task GitHub star prompt) should reuse `.card`, `.btn`, and `.btn-icon`, anchor themselves with tokenized `position: fixed` offsets, and include a mobile `@media (max-width: 768px)` override so they clear the mobile nav/FAB region. + ### Mobile responsive Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-width: 1024px`), 640px (compact), 480px (xs). Mobile overrides go in `@media (max-width: 768px)` blocks at the bottom of `styles.css` after base styles. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 69783f81b5..ec73184f45 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -32,6 +32,7 @@ import { DbCorruptionBanner } from "./components/DbCorruptionBanner"; import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner"; import MergeAdvanceNotice from "./components/MergeAdvanceNotice"; import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner"; +import { GitHubStarPrompt } from "./components/GitHubStarPrompt"; import { OnboardingResumeCard } from "./components/OnboardingResumeCard"; import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations"; import { @@ -44,6 +45,7 @@ import { MobileNavBar } from "./components/MobileNavBar"; import { QuickChatFAB } from "./components/QuickChatFAB"; import { ToastContainer } from "./components/ToastContainer"; import { useBackgroundSessions } from "./hooks/useBackgroundSessions"; +import { useGitHubStarPromptShown, markGitHubStarPromptShown } from "./hooks/useGitHubStarPrompt"; import { useSessionBannersHidden } from "./hooks/useSessionBannerPref"; import { useTasks } from "./hooks/useTasks"; import { useProjects } from "./hooks/useProjects"; @@ -167,6 +169,10 @@ export function didEnterAwaitingApproval(nextStatus: string | undefined, previou return nextStatus === "awaiting-approval" && previousStatus !== "awaiting-approval"; } +export function didEnterDone(nextStatus: string | undefined, previousStatus: string | undefined): boolean { + return nextStatus === "done" && previousStatus !== undefined && previousStatus !== "done"; +} + function parseDateMs(value: string | undefined): number { if (!value) return 0; const parsed = Date.parse(value); @@ -533,9 +539,11 @@ function AppInner() { const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false); const [stashOrphanCount, setStashOrphanCount] = useState(0); const [approvalBannerCandidate, setApprovalBannerCandidate] = useState(null); + const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false); const taskStatusByIdRef = useRef>(new Map()); const seenApprovalKeysRef = useRef>(new Set()); const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); + const gitHubStarPromptShown = useGitHubStarPromptShown(); const refreshMailboxUnreadCount = useCallback(() => { fetchUnreadCount(currentProject?.id) @@ -614,6 +622,9 @@ function AppInner() { const dedupeKey = `task:${payload.id}`; const previousStatus = taskStatusByIdRef.current.get(payload.id); taskStatusByIdRef.current.set(payload.id, payload.status); + if (!gitHubStarPromptShown && didEnterDone(payload.status, previousStatus)) { + setShowGitHubStarPrompt(true); + } if (payload.status !== "awaiting-approval") { seenApprovalKeysRef.current.delete(dedupeKey); approvalDismissalsRef.current.delete(dedupeKey); @@ -637,7 +648,7 @@ function AppInner() { }, }, }); - }, [currentProject?.id, refreshMailboxUnreadCount]); + }, [currentProject?.id, gitHubStarPromptShown, refreshMailboxUnreadCount]); useEffect(() => { if (taskView === "chat") { @@ -1949,6 +1960,14 @@ function AppInner() { }} /> )} + {viewMode === "project" && currentProject && showGitHubStarPrompt && !gitHubStarPromptShown && ( + { + markGitHubStarPromptShown(); + setShowGitHubStarPrompt(false); + }} + /> + )}
diff --git a/packages/dashboard/app/components/GitHubStarPrompt.css b/packages/dashboard/app/components/GitHubStarPrompt.css new file mode 100644 index 0000000000..447ad94df1 --- /dev/null +++ b/packages/dashboard/app/components/GitHubStarPrompt.css @@ -0,0 +1,76 @@ +.github-star-prompt { + position: fixed; + right: var(--space-lg); + bottom: var(--space-lg); + z-index: 1002; + width: min(calc(var(--space-2xl) * 10), calc(100vw - (var(--space-lg) * 2))); + padding: var(--space-lg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-lg); +} + +.github-star-prompt__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); +} + +.github-star-prompt__title-wrap { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + color: var(--text); +} + +.github-star-prompt__title-wrap h3 { + margin: 0; + font-size: var(--space-lg); +} + +.github-star-prompt p { + margin: var(--space-md) 0 0; + color: var(--text-muted); + line-height: 1.5; +} + +.github-star-prompt__actions { + display: flex; + justify-content: flex-start; + margin-top: var(--space-lg); +} + +.github-star-prompt__cta { + display: inline-flex; + align-items: center; + gap: var(--space-sm); +} + +.github-star-prompt__dismiss { + color: var(--text-muted); +} + +.github-star-prompt__dismiss:hover { + color: var(--text); +} + +@media (max-width: 768px) { + .github-star-prompt { + right: var(--space-md); + bottom: calc(var(--space-2xl) * 3 + var(--space-lg)); + left: var(--space-md); + width: auto; + padding: var(--space-md); + } + + .github-star-prompt__actions { + width: 100%; + } + + .github-star-prompt__cta { + width: 100%; + justify-content: center; + } +} diff --git a/packages/dashboard/app/components/GitHubStarPrompt.tsx b/packages/dashboard/app/components/GitHubStarPrompt.tsx new file mode 100644 index 0000000000..a0deb1aa9e --- /dev/null +++ b/packages/dashboard/app/components/GitHubStarPrompt.tsx @@ -0,0 +1,53 @@ +import { Star, X } from "lucide-react"; +import "./GitHubStarPrompt.css"; + +const GITHUB_REPO_URL = "https://github.com/Runfusion/Fusion"; + +interface GitHubStarPromptProps { + onStar?: () => void; + onDismiss: () => void; +} + +export function GitHubStarPrompt({ onStar, onDismiss }: GitHubStarPromptProps) { + const handleStar = () => { + onStar?.(); + onDismiss(); + }; + + return ( +
+
+
+
+ +
+

+ If Fusion has saved you time, a GitHub star goes a long way. It helps other developers discover the + project and keeps the team motivated to ship improvements. +

+ +
+ ); +} + +export { GITHUB_REPO_URL }; diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 45b60c9f6b..357f7648cb 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -593,7 +593,7 @@ vi.mock("../../hooks/useMobileScrollLock", () => ({ _resetLockState: vi.fn(), })); -import { App, didEnterAwaitingApproval } from "../../App"; +import { App, didEnterAwaitingApproval, didEnterDone } from "../../App"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "../../auth"; import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, fetchUnreadCount, updateSettings, runScript, fetchScripts, fetchModels, fetchPluginDashboardViews } from "../../api"; import { __resetShellHostContextForTests } from "../../shell-host"; @@ -923,6 +923,16 @@ describe("didEnterAwaitingApproval", () => { }); }); +describe("didEnterDone", () => { + it("returns true only when status newly enters done", () => { + expect(didEnterDone("done", "in-progress")).toBe(true); + expect(didEnterDone("done", "todo")).toBe(true); + expect(didEnterDone("done", "done")).toBe(false); + expect(didEnterDone("in-progress", "todo")).toBe(false); + expect(didEnterDone("in-progress", undefined)).toBe(false); + }); +}); + describe("App mailbox unread count", () => { it("logs a warning when unread count fetch fails and keeps the zero-count fallback", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/packages/dashboard/app/components/__tests__/GitHubStarPrompt.test.tsx b/packages/dashboard/app/components/__tests__/GitHubStarPrompt.test.tsx new file mode 100644 index 0000000000..3038d8ab77 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/GitHubStarPrompt.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { GITHUB_REPO_URL, GitHubStarPrompt } from "../GitHubStarPrompt"; + +describe("GitHubStarPrompt", () => { + it("renders copy and handles star plus dismiss actions", () => { + const onStar = vi.fn(); + const onDismiss = vi.fn(); + + render(); + + expect(screen.getByText("Enjoying Fusion?")).toBeInTheDocument(); + expect( + screen.getByText( + /If Fusion has saved you time, a GitHub star goes a long way\. It helps other developers discover the project and keeps the team motivated to ship improvements\./, + ), + ).toBeInTheDocument(); + + const starLink = screen.getByRole("link", { name: /star on github/i }); + expect(starLink).toHaveAttribute("href", GITHUB_REPO_URL); + expect(starLink).toHaveAttribute("target", "_blank"); + expect(starLink).toHaveAttribute("rel", "noopener noreferrer"); + + fireEvent.click(starLink); + expect(onStar).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: /dismiss github star prompt/i })); + expect(onDismiss).toHaveBeenCalledTimes(2); + }); + + it("dismisses even when no onStar callback is provided", () => { + const onDismiss = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("link", { name: /star on github/i })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts b/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts new file mode 100644 index 0000000000..3389d4932a --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useGitHubStarPrompt.test.ts @@ -0,0 +1,64 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { markGitHubStarPromptShown, useGitHubStarPromptShown } from "../useGitHubStarPrompt"; + +describe("useGitHubStarPromptShown", () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("returns false by default", () => { + const { result } = renderHook(() => useGitHubStarPromptShown()); + expect(result.current).toBe(false); + }); + + it("marks the prompt shown and persists the flag", () => { + const { result } = renderHook(() => useGitHubStarPromptShown()); + + act(() => { + markGitHubStarPromptShown(); + }); + + expect(result.current).toBe(true); + expect(localStorage.getItem("fusion:github-star-prompt-shown")).toBe("1"); + }); + + it("survives a remount after persistence", () => { + const { unmount } = renderHook(() => useGitHubStarPromptShown()); + + act(() => { + markGitHubStarPromptShown(); + }); + + unmount(); + + const { result } = renderHook(() => useGitHubStarPromptShown()); + expect(result.current).toBe(true); + }); + + it("returns false when localStorage reads fail", () => { + const getItemSpy = vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { + throw new Error("get failed"); + }); + + const { result } = renderHook(() => useGitHubStarPromptShown()); + + expect(result.current).toBe(false); + expect(getItemSpy).toHaveBeenCalled(); + }); + + it("swallows localStorage write errors safely", () => { + const setItemSpy = vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { + throw new Error("set failed"); + }); + + expect(() => { + act(() => { + markGitHubStarPromptShown(); + }); + }).not.toThrow(); + + expect(setItemSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/hooks/useGitHubStarPrompt.ts b/packages/dashboard/app/hooks/useGitHubStarPrompt.ts new file mode 100644 index 0000000000..488a2c5f8a --- /dev/null +++ b/packages/dashboard/app/hooks/useGitHubStarPrompt.ts @@ -0,0 +1,46 @@ +import { useSyncExternalStore } from "react"; + +const STORAGE_KEY = "fusion:github-star-prompt-shown"; +const EVENT_NAME = "fusion:github-star-prompt-changed"; + +function read(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +function subscribe(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) { + onChange(); + } + }; + const handleCustom = () => onChange(); + + window.addEventListener("storage", handleStorage); + window.addEventListener(EVENT_NAME, handleCustom); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(EVENT_NAME, handleCustom); + }; +} + +export function markGitHubStarPromptShown(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, "1"); + window.dispatchEvent(new Event(EVENT_NAME)); + } catch { + // ignore + } +} + +export function useGitHubStarPromptShown(): boolean { + return useSyncExternalStore(subscribe, read, () => false); +}