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:
@@ -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.
|
||||
|
||||
@@ -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" : ""}`}
|
||||
>
|
||||
|
||||
76
packages/dashboard/app/components/GitHubStarPrompt.css
Normal file
76
packages/dashboard/app/components/GitHubStarPrompt.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
packages/dashboard/app/components/GitHubStarPrompt.tsx
Normal file
53
packages/dashboard/app/components/GitHubStarPrompt.tsx
Normal 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 };
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
46
packages/dashboard/app/hooks/useGitHubStarPrompt.ts
Normal file
46
packages/dashboard/app/hooks/useGitHubStarPrompt.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user