fix(frontend): calm update and star prompts

This commit is contained in:
rcourtman
2026-08-25 21:26:36 +01:00
parent 51d49474e4
commit 6341080cea
15 changed files with 422 additions and 376 deletions
@@ -954,13 +954,22 @@ upgrade, update, release, or artifact-selection behavior.
unavailable for source/development builds, and return the canonical release
body without inventing a second changelog source. The update banner may
preview only the curated `Highlights` section from update-check metadata,
while the post-update card must render the release's categorized user-facing
while the post-update card must offer the release's categorized user-facing
change sections (`Added`, `Improved`/`Changed`, `Fixed`, `Security`,
`Breaking changes`, `Deprecated`, or `Removed`) as a changelog once per
later installed release. It must not reuse the Highlights summary as the
post-update content, and must stay silent for a first baseline, malformed or
development versions, missing releases, and releases without categorized
changes. `Highlights` remains a pre-update overview only: release rendering
later installed release. Automatic release communication is limited to a
compact non-blocking update notice; the detailed changelog may open only
after explicit operator action. Preparing that notice records the version
immediately so a reload cannot turn it into a recurring prompt. When the
one-time telemetry disclosure owns the same session, it suppresses the
lower-priority release notice instead of creating consecutive notices.
`frontend-modern/src/utils/localStorage.ts` owns that browser-session notice
reservation boundary so the release notice, telemetry disclosure, and
GitHub gratitude prompt cannot create a one-two sequence. The post-update
surface must not reuse the Highlights summary as its content,
and must stay silent for a first baseline, malformed or development
versions, missing releases, and releases without categorized changes.
`Highlights` remains a pre-update overview only: release rendering
keeps it to at most three short plain-text bullets of no more than 140
characters each, with links, code, issue references, and nested structure
reserved for the categorized or full release notes.
@@ -1080,6 +1080,9 @@ not a replacement status card, CTA band, or page-local nested card.
recurring snooze behavior. It must yield the browser session to an existing
release, disclosure, or other low-priority app-shell notice and suppress
itself for the remainder of a session that presents a blocking dialog.
Low-priority app-shell consumers must coordinate through
`reserveLowPriorityNoticeSession` rather than restoring component-local
cooldowns that can produce consecutive prompts.
Settings selection helpers such as `ResourcePicker` must use the same
`Button` primitive for select-all, clear, and chip remove actions instead of
restoring footer-local action shells.
@@ -4994,7 +4997,8 @@ Clipboard API fallback path and only report copied state after the shared copy
path succeeds.
The running published version must keep a direct `Current release notes` link
in this settings shell so suppressing or dismissing the one-time post-update
notice never makes the changelog undiscoverable.
notice never makes the changelog undiscoverable. Development and source-build
identities must not render that link as if they named a published release.
The update verdict is honest about its age. The frontend serves the update
verdict from a 24-hour localStorage cache, so the panel's "Up to date" state
+25 -19
View File
@@ -1,23 +1,29 @@
{
"version": 1,
"base_sha": "af03903a734f6741360748fbe661cccb45c880ca",
"verified_at": "2026-08-25T19:48:51Z",
"base_sha": "51d49474e461eaca83a75065ba370f3092d6ba34",
"verified_at": "2026-08-25T20:20:32Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Storage/DiskDetail.tsx",
"frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx"
"frontend-modern/src/components/GitHubStarBanner.tsx",
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx",
"frontend-modern/src/components/WhatsNewCard.tsx",
"frontend-modern/src/utils/localStorage.ts"
],
"content_sha256": {
"frontend-modern/src/components/Storage/DiskDetail.tsx": "19487ce71ef93ed4db8c8a36bb76790298408a449313bcb19e7266b1b41e0afc",
"frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx": "bc045c2a053c395154c9a4d0ff5bac32bd1c129ab579032ce282e81acd623426"
"frontend-modern/src/components/GitHubStarBanner.tsx": "7c5b51cb927fa778bd6bb201793a20c084d4e3f6bfab39feb4573fe2759f8c85",
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx": "b0cb063a25f0c9a966522b740afa962163d87a50e241030ac289f1a5679063ad",
"frontend-modern/src/components/WhatsNewCard.tsx": "5e5d7314e46b8aa496f73a42e1abc37a294a3f3ebff3c3c424c33f8d615961d0",
"frontend-modern/src/utils/localStorage.ts": "452dbaa0e4014e0af635b24502ffbe53c738a449670ac6b4c381b3033c53ee0a"
},
"routes": [
"/proxmox/storage"
"/prompt-verification.html",
"/proxmox/overview",
"/settings/system-updates"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 900
},
{
"width": 390,
@@ -25,17 +31,17 @@
}
],
"states": [
"degraded ZFS pool Overview with long node and content values",
"degraded ZFS pool History with collection availability messaging",
"critical NVMe disk Overview without detailed SMART attributes",
"physical disk History with live I/O and SMART history"
"compact post-update notice over an active infrastructure page without a blocking backdrop",
"opt-in categorized release changelog dialog at desktop and phone widths",
"one-time GitHub star prompt at desktop and phone widths",
"dismissed release notice, changelog dialog, and star prompt states",
"System Updates current-version panel in the signed-in development runtime"
],
"interactions": [
"opened the Storage and Physical Disks tabs",
"expanded standalone1-service-pool and switched between Overview and History",
"expanded the WD Blue disk and switched between Overview and History",
"confirmed mobile key-value stacking and wrapping without clipping",
"confirmed the two-column desktop configuration grid remains intact",
"restored the browser viewport after responsive verification"
"opened the changelog from See what's new and closed it with both Escape and Got it",
"dismissed the compact update notice without opening the changelog",
"closed the GitHub star prompt and confirmed it left the page cleanly",
"navigated the signed-in System Updates page and confirmed development builds do not claim published release notes",
"restored the browser viewport and closed all verification tabs"
]
}
@@ -720,7 +720,7 @@
{
"id": "github-star-banner-action-shell",
"category": "action-button",
"summary": "The GitHub star prompt must compose Button and ActionIconButton for its primary, defer, and dismiss actions instead of carrying local floating-prompt button shells.",
"summary": "The one-time GitHub star prompt must compose Button and ActionIconButton for its primary and permanent-dismiss actions instead of carrying local floating-prompt button shells.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "Button"
@@ -1,16 +1,20 @@
import { Show, createSignal, createEffect, onCleanup } from 'solid-js';
import { Show, createSignal, createEffect } from 'solid-js';
import {
createLocalStorageBooleanSignal,
createLocalStorageNumberSignal,
createLocalStorageStringSignal,
reserveLowPriorityNoticeSession,
STORAGE_KEYS,
} from '@/utils/localStorage';
import { useWebSocket } from '@/contexts/appRuntime';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import GithubIcon from 'lucide-solid/icons/github';
import StarIcon from 'lucide-solid/icons/star';
import XIcon from 'lucide-solid/icons/x';
const GITHUB_REPO_URL = 'https://github.com/rcourtman/Pulse';
const ACTIVE_DAYS_BEFORE_PROMPT = 14;
function getTodayDateString(): string {
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD
@@ -19,127 +23,111 @@ function getTodayDateString(): string {
export function GitHubStarBanner() {
const { initialDataReceived, state } = useWebSocket();
// Track if user has dismissed the modal (permanent)
// Closing the prompt is permanent; this is gratitude, not a workflow.
const [dismissed, setDismissed] = createLocalStorageBooleanSignal(
STORAGE_KEYS.GITHUB_STAR_DISMISSED,
false,
);
// Track the first date user had infrastructure connected
const [firstSeenDate, setFirstSeenDate] = createLocalStorageStringSignal(
STORAGE_KEYS.GITHUB_STAR_FIRST_SEEN,
// A rendered prompt counts as its single lifetime appearance even if the
// browser closes before the user chooses an action.
const [promptShown, setPromptShown] = createLocalStorageBooleanSignal(
STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN,
false,
);
const [activeDays, setActiveDays] = createLocalStorageNumberSignal(
STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS,
0,
);
const [lastActiveDate, setLastActiveDate] = createLocalStorageStringSignal(
STORAGE_KEYS.GITHUB_STAR_LAST_ACTIVE_DATE,
'',
);
// Track snooze date (when "Maybe later" was clicked, don't show again until this date)
const [snoozedUntil, setSnoozedUntil] = createLocalStorageStringSignal(
STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL,
'',
);
const [showPrompt, setShowPrompt] = createSignal(false);
let blockedByDialogThisSession = false;
const [showModal, setShowModal] = createSignal(false);
// Check if user qualifies to see the modal
// Count distinct days with connected infrastructure, then offer one quiet
// prompt only when the rest of the app shell has yielded the session.
createEffect(() => {
// Already dismissed? Don't show
if (dismissed()) {
setShowModal(false);
setShowPrompt(false);
return;
}
if (promptShown()) return;
if (dialogStackHasBlockingDialog()) {
blockedByDialogThisSession = true;
setShowPrompt(false);
return;
}
if (blockedByDialogThisSession) return;
if (!initialDataReceived()) {
setShowModal(false);
setShowPrompt(false);
return;
}
// Check if user has connected infrastructure
const hasInfrastructure = (state.resources || []).length > 0;
if (!hasInfrastructure) {
setShowModal(false);
setShowPrompt(false);
return;
}
const today = getTodayDateString();
const firstSeen = firstSeenDate();
// First time seeing infrastructure? Record the date, don't show yet
if (!firstSeen) {
setFirstSeenDate(today);
setShowModal(false);
if (lastActiveDate() !== today) {
setLastActiveDate(today);
setActiveDays(Math.min(activeDays() + 1, ACTIVE_DAYS_BEFORE_PROMPT));
return;
}
// Still within snooze period? Don't show
const snoozeDate = snoozedUntil();
if (snoozeDate && today < snoozeDate) {
setShowModal(false);
return;
}
if (activeDays() < ACTIVE_DAYS_BEFORE_PROMPT) return;
if (!reserveLowPriorityNoticeSession('github-star')) return;
// Returning user (different day than first seen)? Show the modal
if (firstSeen !== today) {
setShowModal(true);
}
setShowPrompt(true);
setPromptShown(true);
});
const handleDismiss = () => {
setDismissed(true);
setShowModal(false);
setShowPrompt(false);
};
const handleStarClick = () => {
window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
// Auto-dismiss - trust that they starred
setDismissed(true);
setShowModal(false);
setShowPrompt(false);
};
const handleMaybeLater = () => {
// Snooze for 7 days before showing again
const snoozeDate = new Date();
snoozeDate.setDate(snoozeDate.getDate() + 7);
setSnoozedUntil(snoozeDate.toISOString().split('T')[0]);
setShowModal(false);
};
createEffect(() => {
if (!showModal()) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
handleMaybeLater();
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
return (
<Show when={showModal()}>
<Show when={showPrompt()}>
<section
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-sm overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-lg md:right-auto md:bottom-4"
aria-labelledby="github-star-title"
aria-live="polite"
>
<div class="flex items-start gap-3 p-4">
<div class="flex items-start gap-3 p-3">
<div
class="relative mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-hover"
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-hover"
aria-hidden="true"
>
<GithubIcon class="h-5 w-5 text-base-content" />
<div class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-yellow-400 shadow-sm">
<StarIcon class="h-3 w-3 text-yellow-800" />
</div>
<GithubIcon class="h-4 w-4 text-base-content" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<h2 id="github-star-title" class="text-sm font-semibold text-base-content">
Enjoying Pulse?
Finding Pulse useful?
</h2>
<p class="mt-1 text-xs leading-5 text-muted">
Pulse is built and maintained by an independent developer. If it's been useful for
monitoring your infrastructure, a GitHub star helps more than you'd think.
Starring the project on GitHub helps others discover it.
</p>
</div>
<ActionIconButton
@@ -153,7 +141,7 @@ export function GitHubStarBanner() {
<XIcon class="h-4 w-4" aria-hidden="true" />
</ActionIconButton>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<div class="mt-2 flex flex-wrap gap-2">
<Button
onClick={handleStarClick}
variant="primary"
@@ -164,9 +152,6 @@ export function GitHubStarBanner() {
<StarIcon class="h-4 w-4" aria-hidden="true" />
Star on GitHub
</Button>
<Button onClick={handleMaybeLater} variant="ghost" size="mdCompact" type="button">
Maybe later
</Button>
</div>
</div>
</div>
@@ -12,9 +12,15 @@ import ArrowRight from 'lucide-solid/icons/arrow-right';
import Package from 'lucide-solid/icons/package';
import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates';
import { runtimeCapabilities } from '@/stores/license';
import { buildDockerImageTag, buildLinuxAmd64DownloadCommand } from '@/components/updateVersion';
import {
buildDockerImageTag,
buildLinuxAmd64DownloadCommand,
buildReleaseNotesUrl,
} from '@/components/updateVersion';
import { isReleaseVersion } from '@/components/whatsNewModel';
import { UpdateInstallGuide } from '@/components/Settings/UpdateInstallGuide';
import { UpdateHistorySection } from '@/components/Settings/UpdateHistorySection';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
import {
getUpdateChannelCardOptions,
type UpdateChannelOptionValue,
@@ -122,6 +128,21 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
{(badge) => <span class={badge.className}>{badge.label}</span>}
</For>
</div>
<Show
when={
!props.versionInfo()?.isDevelopment &&
!props.versionInfo()?.isSourceBuild &&
isReleaseVersion(props.versionInfo()?.version ?? '')
}
>
<ExternalTextLink
href={buildReleaseNotesUrl(props.versionInfo()?.version)}
variant="compactAction"
class="mt-1 -ml-1"
>
Current release notes
</ExternalTextLink>
</Show>
</div>
</div>
</div>
@@ -33,6 +33,40 @@ describe('settings switch labels', () => {
expect(screen.getByRole('checkbox', { name: 'Automatic Stable Updates' })).toBeInTheDocument();
});
it('keeps the running release notes available from update settings', () => {
const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable');
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
render(() => (
<UpdatesSettingsPanel
versionInfo={() => ({
version: '6.4.0',
build: 'community',
runtime: 'server',
isDocker: false,
isSourceBuild: false,
isDevelopment: false,
})}
updateInfo={() => null}
checkingForUpdates={() => false}
updateChannel={updateChannel}
setUpdateChannel={setUpdateChannel}
autoUpdateEnabled={autoUpdateEnabled}
setAutoUpdateEnabled={setAutoUpdateEnabled}
checkForUpdates={vi.fn().mockResolvedValue(undefined)}
setHasUnsavedChanges={vi.fn()}
updatePlan={() => null}
onInstallUpdate={vi.fn()}
isInstalling={() => false}
/>
));
expect(screen.getByRole('link', { name: 'Current release notes' })).toHaveAttribute(
'href',
'https://github.com/rcourtman/Pulse/releases/tag/v6.4.0',
);
});
it('associates the backup polling switch with its visible label', () => {
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
const [backupPollingInterval, setBackupPollingInterval] = createSignal(3600);
@@ -1435,6 +1435,15 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps published release notes discoverable from the current-version card', () => {
expect(updatesSettingsPanelSource).toContain('Current release notes');
expect(updatesSettingsPanelSource).toContain('buildReleaseNotesUrl');
expect(updatesSettingsPanelSource).toContain('isReleaseVersion');
expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isDevelopment');
expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isSourceBuild');
expect(updatesSettingsPanelSource).toContain('variant="compactAction"');
});
it('keeps the update verdict honest about the age of the check behind it', () => {
// The displayed verdict can come from the 24h localStorage cache, so the
// Up to date state must carry the age of the check it came from through
+62 -18
View File
@@ -1,10 +1,11 @@
import { Show, createEffect, createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import InfoIcon from 'lucide-solid/icons/info';
import XIcon from 'lucide-solid/icons/x';
import { updateStore } from '@/stores/updates';
import { UpdatesAPI } from '@/api/updates';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { reserveLowPriorityNoticeSession, STORAGE_KEYS } from '@/utils/localStorage';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
@@ -55,10 +56,11 @@ const markTelemetryPayloadNoticeSeen = () => {
};
/**
* Post-update "What's New" dialog. Shows once after the running version
* changes, and only when that release has categorized user-facing changelog
* entries in its GitHub release notes. Dismissing (or a release without those
* entries) records the version so the dialog stays quiet until the next update.
* Post-update "What's New" notice. A compact non-blocking notice appears once
* after the running version changes and only when that release has categorized
* user-facing changelog entries. The detailed dialog opens only after an
* explicit action. Preparing the notice (or finding no categorized entries)
* records the version so reloads stay quiet until the next update.
*
* This release communication boundary also owns the one-time, non-blocking
* telemetry schema v2 notice. Existing installations see it once; fresh
@@ -66,15 +68,22 @@ const markTelemetryPayloadNoticeSeen = () => {
*/
export function WhatsNewCard() {
const navigate = useNavigate();
const [visible, setVisible] = createSignal(false);
const [noticeVisible, setNoticeVisible] = createSignal(false);
const [dialogVisible, setDialogVisible] = createSignal(false);
const [telemetryNoticeVisible, setTelemetryNoticeVisible] = createSignal(false);
const [version, setVersion] = createSignal('');
const [changelogHtml, setChangelogHtml] = createSignal('');
const hadPriorReleaseBaseline = readLastSeenVersion() !== null;
const telemetryNoticeAlreadySeen =
readTelemetryPayloadNoticeVersion() === TELEMETRY_PAYLOAD_NOTICE_VERSION;
const telemetryNoticeNeedsSession = hadPriorReleaseBaseline && !telemetryNoticeAlreadySeen;
if (telemetryNoticeNeedsSession) {
reserveLowPriorityNoticeSession('telemetry-update');
}
let checked = false;
let telemetryNoticeChecked = false;
const loadNotes = async (currentVersion: string) => {
const loadNotes = async (currentVersion: string, noticeSlotReserved: boolean) => {
try {
const notes = await UpdatesAPI.getReleaseNotes();
// Mid-update the backend can briefly disagree with the UI about the
@@ -89,7 +98,12 @@ export function WhatsNewCard() {
}
setChangelogHtml(renderMarkdown(changelog));
setVersion(currentVersion);
setVisible(true);
// The compact notice is the only automatic release UI. Record the
// version immediately so reloads cannot turn it into a recurring prompt.
markVersionSeen(currentVersion);
if (noticeSlotReserved) {
setNoticeVisible(true);
}
} catch (error) {
if ((error as { status?: number }).status === 404) {
// No published release for this build — stop asking.
@@ -124,7 +138,8 @@ export function WhatsNewCard() {
return;
}
void loadNotes(currentVersion);
const noticeSlotReserved = reserveLowPriorityNoticeSession('release-update');
void loadNotes(currentVersion, noticeSlotReserved);
});
createEffect(() => {
@@ -149,10 +164,17 @@ export function WhatsNewCard() {
setTelemetryNoticeVisible(true);
});
// Any close path (button, backdrop, Escape) counts as seen.
const dismiss = () => {
markVersionSeen(version());
setVisible(false);
const dismissNotice = () => {
setNoticeVisible(false);
};
const openChangelog = () => {
setNoticeVisible(false);
setDialogVisible(true);
};
const dismissChangelog = () => {
setDialogVisible(false);
};
const dismissTelemetryNotice = () => {
@@ -212,10 +234,32 @@ export function WhatsNewCard() {
</InlineNotice>
</Show>
<Show when={visible()}>
<Show when={noticeVisible()}>
<aside
class="fixed bottom-[var(--pulse-mobile-nav-height)] left-4 right-4 z-30 max-w-sm md:right-auto md:bottom-4"
aria-live="polite"
data-testid="whats-new-notice"
>
<InlineNotice
tone="success"
icon={<CheckCircleIcon class="h-4 w-4" aria-hidden="true" />}
actionLabel="See what's new"
actionOnClick={openChangelog}
actionAriaLabel={`See what's new in Pulse ${version()}`}
onDismiss={dismissNotice}
dismissLabel="Dismiss update notice"
dismissTitle="Dismiss"
class="shadow-lg"
>
<span class="font-semibold">Pulse updated to v{version()}</span>
</InlineNotice>
</aside>
</Show>
<Show when={dialogVisible()}>
<Dialog
isOpen={visible()}
onClose={dismiss}
isOpen={dialogVisible()}
onClose={dismissChangelog}
panelClass="max-w-xl"
ariaLabelledBy="whats-new-title"
>
@@ -250,7 +294,7 @@ export function WhatsNewCard() {
</div>
</div>
<ActionIconButton
onClick={dismiss}
onClick={dismissChangelog}
label="Dismiss what's new"
title="Close"
tone="muted"
@@ -276,7 +320,7 @@ export function WhatsNewCard() {
>
Full release notes
</ExternalTextLink>
<Button onClick={dismiss} variant="primary" size="md" type="button">
<Button onClick={dismissChangelog} variant="primary" size="md" type="button">
Got it
</Button>
</div>
@@ -1,12 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import gitHubStarBannerSource from '../GitHubStarBanner.tsx?raw';
/* ------------------------------------------------------------------ */
/* Mocks */
/* ------------------------------------------------------------------ */
type BannerResource = { id: string; name: string };
const wsState = vi.hoisted(() => ({ resources: [] as BannerResource[] }));
@@ -23,43 +18,41 @@ vi.mock('@/utils/logger', () => ({
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const DISMISSED_KEY = 'pulse-github-star-dismissed';
const FIRST_SEEN_KEY = 'pulse-github-star-first-seen';
const SNOOZED_KEY = 'pulse-github-star-snoozed-until';
const PROMPT_SHOWN_KEY = 'pulse-github-star-prompt-shown';
const ACTIVE_DAYS_KEY = 'pulse-github-star-active-days';
const LAST_ACTIVE_DATE_KEY = 'pulse-github-star-last-active-date';
const LOW_PRIORITY_NOTICE_OWNER_KEY = 'pulse-low-priority-notice-owner';
const LOCAL_DISMISS_BUTTON_CLASS =
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
const LOCAL_PRIMARY_BUTTON_CLASS =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700';
const LOCAL_DEFER_BUTTON_CLASS =
'inline-flex min-h-9 items-center justify-center rounded-md px-3 py-2 text-sm text-muted transition-colors hover:bg-surface-hover hover:text-base-content';
async function renderBanner() {
const mod = await import('../GitHubStarBanner');
render(() => <mod.GitHubStarBanner />);
}
/** Seed N fake websocket resources into runtime state. */
function setResourceCount(n: number) {
wsState.resources = Array.from({ length: n }, (_, i) => ({
id: `res-${i}`,
name: `Resource ${i}`,
function setResourceCount(count: number) {
wsState.resources = Array.from({ length: count }, (_, index) => ({
id: `res-${index}`,
name: `Resource ${index}`,
}));
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
function seedEligibleEngagement() {
localStorage.setItem(ACTIVE_DAYS_KEY, '13');
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13');
}
describe('GitHubStarBanner', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-14T12:00:00Z'));
wsState.resources = [];
mockInitialDataReceived.mockReturnValue(true);
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
@@ -68,175 +61,107 @@ describe('GitHubStarBanner', () => {
cleanup();
});
/* ---------- Visibility: not shown scenarios ---------- */
it('does not render when there are no resources', async () => {
wsState.resources = [];
it('stays quiet without connected infrastructure', async () => {
await renderBanner();
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0');
});
it('keeps the shell banner on websocket runtime state instead of reopening unified resources', () => {
expect(gitHubStarBannerSource).toContain('useWebSocket');
expect(gitHubStarBannerSource).toContain('initialDataReceived');
expect(gitHubStarBannerSource).not.toContain('useResources');
});
it('renders as a non-modal prompt so Assistant remains available', () => {
expect(gitHubStarBannerSource).not.toContain('<Dialog');
expect(gitHubStarBannerSource).toContain('aria-live="polite"');
expect(gitHubStarBannerSource).toContain('z-30');
});
it('routes prompt action chrome through shared Button primitives', () => {
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
expect(gitHubStarBannerSource).toContain('<Button');
expect(gitHubStarBannerSource).not.toContain(LOCAL_DISMISS_BUTTON_CLASS);
expect(gitHubStarBannerSource).not.toContain(LOCAL_PRIMARY_BUTTON_CLASS);
expect(gitHubStarBannerSource).not.toContain(LOCAL_DEFER_BUTTON_CLASS);
});
it('does not render on the first day infrastructure is seen (records first-seen date)', async () => {
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
setResourceCount(3);
await renderBanner();
// Should not show prompt
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
// Should have recorded today as first-seen date
expect(localStorage.getItem(FIRST_SEEN_KEY)).toBe('2026-03-01');
});
it('does not render before websocket initial data is available', async () => {
it('waits for websocket initial data before recording engagement', async () => {
mockInitialDataReceived.mockReturnValue(false);
setResourceCount(3);
await renderBanner();
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
const stored = localStorage.getItem(FIRST_SEEN_KEY);
expect(stored === null || stored === '').toBe(true);
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0');
});
it('does not render when first-seen date is today (same day)', async () => {
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
it('counts one distinct active day without showing the prompt', async () => {
setResourceCount(3);
await renderBanner();
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('1'));
expect(localStorage.getItem(LAST_ACTIVE_DATE_KEY)).toBe('2026-03-14');
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
});
it('does not render when permanently dismissed', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
localStorage.setItem(DISMISSED_KEY, 'true');
it('does not count repeated sessions on the same day', async () => {
localStorage.setItem(ACTIVE_DAYS_KEY, '7');
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-14');
setResourceCount(3);
await renderBanner();
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('7');
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
});
it('does not render when within snooze period', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // snoozed until the 10th
setResourceCount(3);
await renderBanner();
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
/* ---------- Visibility: shown scenarios ---------- */
it('renders when returning user has infrastructure (different day than first seen)', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
it('shows one compact prompt after fourteen distinct active days', async () => {
seedEligibleEngagement();
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument();
});
});
it('renders when snooze period has expired', async () => {
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // expired 5 days ago
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
});
/* ---------- Content ---------- */
it('displays the expected content when shown', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
expect(screen.getByText(/independent developer/)).toBeInTheDocument();
expect(
screen.getByText('Starring the project on GitHub helps others discover it.'),
).toBeInTheDocument();
expect(screen.getByText('Star on GitHub')).toBeInTheDocument();
expect(screen.getByText('Maybe later')).toBeInTheDocument();
expect(screen.getByLabelText("Close and don't show again")).toBeInTheDocument();
expect(screen.queryByText('Maybe later')).not.toBeInTheDocument();
expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14');
expect(localStorage.getItem(PROMPT_SHOWN_KEY)).toBe('true');
});
/* ---------- Dismiss (X button) ---------- */
it('hides the prompt and persists dismissal when the X button is clicked', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
it('never reopens after its single lifetime appearance', async () => {
localStorage.setItem(PROMPT_SHOWN_KEY, 'true');
localStorage.setItem(ACTIVE_DAYS_KEY, '14');
localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13');
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
});
const closeBtn = screen.getByLabelText("Close and don't show again");
fireEvent.click(closeBtn);
it('stays quiet when another low-priority notice owns the session', async () => {
sessionStorage.setItem(LOW_PRIORITY_NOTICE_OWNER_KEY, 'release-update');
seedEligibleEngagement();
setResourceCount(3);
await renderBanner();
await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14'));
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
expect(localStorage.getItem(PROMPT_SHOWN_KEY)).not.toBe('true');
});
it('permanently dismisses the prompt from the close action', async () => {
seedEligibleEngagement();
setResourceCount(3);
await renderBanner();
await waitFor(() => expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText("Close and don't show again"));
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument();
});
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
});
/* ---------- Star on GitHub ---------- */
it('opens GitHub repo, dismisses permanently on star click', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
it('opens GitHub and permanently dismisses on the star action', async () => {
seedEligibleEngagement();
setResourceCount(3);
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Star on GitHub')).toBeInTheDocument();
});
await waitFor(() => expect(screen.getByText('Star on GitHub')).toBeInTheDocument());
fireEvent.click(screen.getByText('Star on GitHub'));
@@ -245,119 +170,26 @@ describe('GitHubStarBanner', () => {
'_blank',
'noopener,noreferrer',
);
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
// Permanent dismissal
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
});
/* ---------- Maybe later (snooze) ---------- */
it('hides the prompt and sets a 7-day snooze when "Maybe later" is clicked', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Maybe later')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Maybe later'));
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
// Should snooze for 7 days from today
expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12');
// Should NOT permanently dismiss
expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true');
it('uses runtime state and remains a non-blocking app-shell prompt', () => {
expect(gitHubStarBannerSource).toContain('useWebSocket');
expect(gitHubStarBannerSource).toContain('initialDataReceived');
expect(gitHubStarBannerSource).not.toContain('useResources');
expect(gitHubStarBannerSource).not.toContain('<Dialog');
expect(gitHubStarBannerSource).toContain('dialogStackHasBlockingDialog');
expect(gitHubStarBannerSource).toContain('reserveLowPriorityNoticeSession');
expect(gitHubStarBannerSource).toContain('aria-live="polite"');
expect(gitHubStarBannerSource).toContain('z-30');
});
it('snoozes on Escape without registering as a blocking dialog', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
setResourceCount(3);
expect(dialogStackHasBlockingDialog()).toBe(false);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(dialogStackHasBlockingDialog()).toBe(false);
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
expect(dialogStackHasBlockingDialog()).toBe(false);
expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12');
expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true');
});
/* ---------- Edge cases ---------- */
it('renders on the exact snooze expiry date (snooze date == today)', async () => {
vi.setSystemTime(new Date('2026-03-10T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // today === snooze date, so today < snooze is false
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
});
it('does not write first-seen date when there are no resources', async () => {
vi.setSystemTime(new Date('2026-03-01T12:00:00Z'));
wsState.resources = [];
await renderBanner();
// The localStorage signal initializes with '' and syncs it, but the
// component never sets it to a real date when there are no resources.
const stored = localStorage.getItem(FIRST_SEEN_KEY);
expect(stored === null || stored === '').toBe(true);
});
it('stays dismissed across re-renders after clicking X', async () => {
vi.setSystemTime(new Date('2026-03-05T12:00:00Z'));
localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01');
setResourceCount(3);
await renderBanner();
await waitFor(() => {
expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Close and don't show again"));
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
// Verify localStorage has dismissed flag persisted
expect(localStorage.getItem(DISMISSED_KEY)).toBe('true');
// Clean up and re-render — should stay hidden due to persisted dismissal
cleanup();
await renderBanner();
await waitFor(() => {
expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument();
});
it('routes prompt actions through shared Button primitives', () => {
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
expect(gitHubStarBannerSource).toContain('<Button');
expect(gitHubStarBannerSource).not.toContain(LOCAL_DISMISS_BUTTON_CLASS);
expect(gitHubStarBannerSource).not.toContain(LOCAL_PRIMARY_BUTTON_CLASS);
});
});
@@ -367,8 +199,6 @@ describe('GitHubStarBanner mobile navigation clearance', () => {
});
it('does not keep its own copy of the bar height', () => {
// This banner was one of five sites hardcoding 5rem for a bar that
// measures ~45px. The bar publishes its measured height instead.
expect(gitHubStarBannerSource).not.toMatch(/5rem\s*\+\s*env\(safe-area-inset-bottom/);
});
@@ -32,6 +32,7 @@ describe('WhatsNewCard', () => {
getReleaseNotesMock.mockReset();
navigateMock.mockReset();
localStorage.clear();
sessionStorage.clear();
});
afterEach(cleanup);
@@ -126,8 +127,9 @@ describe('WhatsNewCard', () => {
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
});
it('shows the categorized changelog after the running release changes', async () => {
it('announces the running release without opening a blocking dialog', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
@@ -155,8 +157,18 @@ describe('WhatsNewCard', () => {
await renderCard();
await waitFor(() => {
expect(screen.getByTestId('whats-new-modal')).toBeInTheDocument();
expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument();
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.getByText('Pulse updated to v6.1.0-rc.1')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" }),
).toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
fireEvent.click(screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" }));
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Pulse v6.1.0-rc.1 changelog')).toBeInTheDocument();
expect(screen.getByText('Added')).toBeInTheDocument();
@@ -175,8 +187,9 @@ describe('WhatsNewCard', () => {
);
});
it('persists dismissal for the current release', async () => {
it('dismisses the compact notice without reopening it on reload', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
@@ -190,14 +203,48 @@ describe('WhatsNewCard', () => {
});
await renderCard();
await waitFor(() => expect(screen.getByText('Got it')).toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument());
fireEvent.click(screen.getByText('Got it'));
fireEvent.click(screen.getByRole('button', { name: 'Dismiss update notice' }));
await waitFor(() => {
expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument();
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
});
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
cleanup();
await renderCard();
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('keeps the release notice quiet when the telemetry disclosure owns the session', async () => {
localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5');
versionInfoMock.mockReturnValue({
version: '6.1.0-rc.1',
isDevelopment: false,
isSourceBuild: false,
});
getReleaseNotesMock.mockResolvedValue({
version: '6.1.0-rc.1',
releaseNotes: '## Improved\n- Faster resource updates.',
releaseDate: '2026-07-13T12:00:00Z',
isPrerelease: true,
});
await renderCard();
await waitFor(() => {
expect(screen.getByTestId('telemetry-payload-update-notice')).toBeInTheDocument();
expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1');
});
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dismiss telemetry payload update' }));
expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument();
expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument();
});
it('stays quiet when a release has only a highlights summary', async () => {
@@ -252,4 +252,12 @@ describe('post-update telemetry disclosure', () => {
expect(whatsNewCardSource).toContain("openTelemetrySettings('disable')");
expect(whatsNewCardSource).toContain('PRIVACY_DOC_URL');
});
it('keeps release details opt-in and coordinates the automatic notice session', () => {
expect(whatsNewCardSource).toContain("reserveLowPriorityNoticeSession('release-update')");
expect(whatsNewCardSource).toContain('markVersionSeen(currentVersion);');
expect(whatsNewCardSource).toContain('actionLabel="See what\'s new"');
expect(whatsNewCardSource).toContain('setDialogVisible(true)');
expect(whatsNewCardSource).not.toContain('setDialogVisible(true);\n markVersionSeen');
});
});
@@ -5130,6 +5130,11 @@ describe('shared primitive guardrails', () => {
expect(gitHubStarBannerSource).toContain('@/components/shared/Button');
expect(gitHubStarBannerSource).toContain('<ActionIconButton');
expect(gitHubStarBannerSource).toContain('<Button');
expect(gitHubStarBannerSource).toContain('const ACTIVE_DAYS_BEFORE_PROMPT = 14;');
expect(gitHubStarBannerSource).toContain("reserveLowPriorityNoticeSession('github-star')");
expect(gitHubStarBannerSource).toContain('GITHUB_STAR_PROMPT_SHOWN');
expect(gitHubStarBannerSource).not.toContain('Maybe later');
expect(gitHubStarBannerSource).not.toContain('GITHUB_STAR_SNOOZED_UNTIL');
expect(gitHubStarBannerSource).not.toContain(
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content',
);
@@ -4,6 +4,8 @@ import {
createLocalStorageBooleanSignal,
createLocalStorageNumberSignal,
createLocalStorageStringSignal,
reserveLowPriorityNoticeSession,
SESSION_STORAGE_KEYS,
STORAGE_KEYS,
} from '@/utils/localStorage';
@@ -62,6 +64,7 @@ function dispatchStorageEvent(key: string, newValue: string | null, storageArea:
describe('localStorage signals', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
afterEach(() => {
@@ -83,7 +86,23 @@ describe('localStorage signals', () => {
it('exposes a broad set of keys across feature areas', () => {
expect(Object.keys(STORAGE_KEYS).length).toBeGreaterThan(40);
expect(STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY).toBe('workloadsSearchHistory');
expect(STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL).toBe('pulse-github-star-snoozed-until');
expect(STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS).toBe('pulse-github-star-active-days');
expect(STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN).toBe('pulse-github-star-prompt-shown');
});
});
describe('low-priority notice sessions', () => {
it('allows one owner to reserve and reuse the session notice slot', () => {
expect(reserveLowPriorityNoticeSession('release-update')).toBe(true);
expect(reserveLowPriorityNoticeSession('release-update')).toBe(true);
expect(sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER)).toBe(
'release-update',
);
});
it('rejects a second low-priority notice owner in the same session', () => {
expect(reserveLowPriorityNoticeSession('telemetry-update')).toBe(true);
expect(reserveLowPriorityNoticeSession('github-star')).toBe(false);
});
});
+27 -2
View File
@@ -138,6 +138,30 @@ export function createLocalStorageStringSignal(
);
}
export type LowPriorityNoticeOwner = 'github-star' | 'release-update' | 'telemetry-update';
export const SESSION_STORAGE_KEYS = {
LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner',
} as const;
/**
* Reserve the single low-priority app-shell notice slot for this browser
* session. Informational and promotional prompts use this boundary so a user
* never has to clear a sequence of unrelated notices after opening Pulse.
*/
export function reserveLowPriorityNoticeSession(owner: LowPriorityNoticeOwner): boolean {
if (typeof sessionStorage === 'undefined') return true;
try {
const existingOwner = sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER);
if (existingOwner) return existingOwner === owner;
sessionStorage.setItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER, owner);
return true;
} catch {
// Storage-disabled browsers still get the notice's own persistence rules.
return true;
}
}
/**
* Creates a number signal that syncs with localStorage
* @param key - The localStorage key
@@ -208,8 +232,9 @@ export const STORAGE_KEYS = {
// GitHub star prompt
GITHUB_STAR_DISMISSED: 'pulse-github-star-dismissed',
GITHUB_STAR_FIRST_SEEN: 'pulse-github-star-first-seen',
GITHUB_STAR_SNOOZED_UNTIL: 'pulse-github-star-snoozed-until',
GITHUB_STAR_PROMPT_SHOWN: 'pulse-github-star-prompt-shown',
GITHUB_STAR_ACTIVE_DAYS: 'pulse-github-star-active-days',
GITHUB_STAR_LAST_ACTIVE_DATE: 'pulse-github-star-last-active-date',
// Billing / migration guidance
AGENT_MIGRATION_NOTICE_DISMISSED: 'pulse-agent-migration-notice-dismissed',