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
This commit is contained in:
gsxdsm
2026-06-03 17:11:16 -07:00
parent 4625d644a8
commit 962b97ce84
8 changed files with 313 additions and 2 deletions

View File

@@ -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.

View File

@@ -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<ApprovalBannerCandidate | null>(null);
const [showGitHubStarPrompt, setShowGitHubStarPrompt] = useState(false);
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
const approvalDismissalsRef = useRef<Map<string, number>>(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 && (
<GitHubStarPrompt
onDismiss={() => {
markGitHubStarPromptShown();
setShowGitHubStarPrompt(false);
}}
/>
)}
<div
className={`project-content${viewMode === "project" && currentProject && (!isMobile || !mobileKeyboardOpen) ? " project-content--with-footer" : ""}${isMobile && !mobileKeyboardOpen ? " project-content--with-mobile-nav" : ""}`}
>

View File

@@ -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;
}
}

View File

@@ -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 (
<section className="card github-star-prompt" role="region" aria-live="polite" aria-label="GitHub star prompt">
<div className="github-star-prompt__header">
<div className="github-star-prompt__title-wrap">
<Star aria-hidden="true" />
<h3>Enjoying Fusion?</h3>
</div>
<button
type="button"
className="btn-icon github-star-prompt__dismiss"
onClick={onDismiss}
aria-label="Dismiss GitHub star prompt"
>
<X aria-hidden="true" />
</button>
</div>
<p>
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.
</p>
<div className="github-star-prompt__actions">
<a
className="btn btn-sm github-star-prompt__cta"
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
onClick={handleStar}
>
<Star aria-hidden="true" />
<span>Star on GitHub</span>
</a>
</div>
</section>
);
}
export { GITHUB_REPO_URL };

View File

@@ -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(() => {});

View File

@@ -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(<GitHubStarPrompt onStar={onStar} onDismiss={onDismiss} />);
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(<GitHubStarPrompt onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("link", { name: /star on github/i }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View File

@@ -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();
});
});

View File

@@ -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);
}