From ec7a5a5ba4d0be23afb4d57c72cc9b0f814a837c Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:49:02 +0200 Subject: [PATCH 01/21] feat: add feature announcements --- .../emdash-desktop/feature-announcements.toml | 29 ++++ apps/emdash-desktop/src/main/app/menu.ts | 4 +- .../core/feature-announcements/controller.ts | 31 ++++ .../core/feature-announcements/service.ts | 115 ++++++++++++++ apps/emdash-desktop/src/main/rpc.ts | 2 + .../src/renderer/app/workspace.tsx | 2 + .../feature-announcement-card.tsx | 149 ++++++++++++++++++ .../feature-announcement-icon.tsx | 23 +++ .../feature-announcement-store.test.ts | 64 ++++++++ .../feature-announcement-store.ts | 125 +++++++++++++++ .../components/AnnouncementPreviewRow.tsx | 30 ++++ .../settings/components/SettingsPage.tsx | 2 + .../src/renderer/lib/stores/app-state.ts | 3 + apps/emdash-desktop/src/renderer/main.tsx | 1 + .../shared/feature-announcements/constants.ts | 20 +++ .../feature-announcements/schema.test.ts | 52 ++++++ .../shared/feature-announcements/schema.ts | 62 ++++++++ apps/emdash-desktop/src/shared/urls.ts | 1 + 18 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 apps/emdash-desktop/feature-announcements.toml create mode 100644 apps/emdash-desktop/src/main/core/feature-announcements/controller.ts create mode 100644 apps/emdash-desktop/src/main/core/feature-announcements/service.ts create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-icon.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts create mode 100644 apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx create mode 100644 apps/emdash-desktop/src/shared/feature-announcements/constants.ts create mode 100644 apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts create mode 100644 apps/emdash-desktop/src/shared/feature-announcements/schema.ts diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml new file mode 100644 index 0000000000..5d43bd6919 --- /dev/null +++ b/apps/emdash-desktop/feature-announcements.toml @@ -0,0 +1,29 @@ +# In-app feature highlight manifest. +# +# Edit this file on GitHub to announce major features without shipping a new app +# release. Set `enabled = false` to hide the card while keeping content around +# for the next launch. +# +# The app fetches this file from the main branch at startup. + +enabled = true +id = "automations-2026-06" +eyebrow = "Now available" +title = "Emdash Automations" +changelogUrl = "https://emdash.sh/changelog" +learnMoreUrl = "https://docs.emdash.sh" +minAppVersion = "1.1.27" + +[[features]] +icon = "calendar-clock" +title = "Run agents on a schedule" +description = "Launch coding agents on a cron — nightly dependency audits, issue triage, recurring chores." + +[[features]] +icon = "list-checks" +title = "Every run in one place" +description = "Follow live status and review the results of past runs without leaving the app." + +[cta] +label = "Open Automations" +view = "automations" diff --git a/apps/emdash-desktop/src/main/app/menu.ts b/apps/emdash-desktop/src/main/app/menu.ts index cb3eba8ac9..451a464345 100644 --- a/apps/emdash-desktop/src/main/app/menu.ts +++ b/apps/emdash-desktop/src/main/app/menu.ts @@ -10,7 +10,7 @@ import { menuRedoChannel, menuUndoChannel, } from '@shared/events/appEvents'; -import { EMDASH_DOCS_URL, EMDASH_ISSUES_NEW_URL, EMDASH_RELEASES_URL } from '@shared/urls'; +import { EMDASH_CHANGELOG_URL, EMDASH_DOCS_URL, EMDASH_ISSUES_NEW_URL } from '@shared/urls'; import { getMainWindow } from './window'; function copyInstallationId(): void { @@ -168,7 +168,7 @@ export function setupApplicationMenu(): void { { label: 'Changelog', click: () => { - void shell.openExternal(EMDASH_RELEASES_URL); + void shell.openExternal(EMDASH_CHANGELOG_URL); }, }, { type: 'separator' as const }, diff --git a/apps/emdash-desktop/src/main/core/feature-announcements/controller.ts b/apps/emdash-desktop/src/main/core/feature-announcements/controller.ts new file mode 100644 index 0000000000..0543f6fe29 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/feature-announcements/controller.ts @@ -0,0 +1,31 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { featureAnnouncementsService } from './service'; + +export const featureAnnouncementsController = createRPCController({ + getCurrent: async () => { + try { + const manifest = await featureAnnouncementsService.getCurrent(); + return { success: true as const, data: manifest }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + preview: async () => { + if (!import.meta.env.DEV) { + return { success: false as const, error: 'Preview is only available in development builds' }; + } + + try { + const manifest = await featureAnnouncementsService.preview(); + return { success: true as const, data: manifest }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); diff --git a/apps/emdash-desktop/src/main/core/feature-announcements/service.ts b/apps/emdash-desktop/src/main/core/feature-announcements/service.ts new file mode 100644 index 0000000000..4d905d5bf0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/feature-announcements/service.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { app } from 'electron'; +import semver from 'semver'; +import * as toml from 'smol-toml'; +import { resolveAppVersion } from '@main/core/app/utils'; +import { log } from '@main/lib/logger'; +import { + FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME, + FEATURE_ANNOUNCEMENT_MANIFEST_URL, +} from '@shared/feature-announcements/constants'; +import { + parseFeatureAnnouncementManifest, + parseFeatureAnnouncementManifestRaw, + type FeatureAnnouncementManifest, +} from '@shared/feature-announcements/schema'; + +const MANIFEST_CACHE_TTL_MS = 15 * 60 * 1000; + +type CachedManifest = { + fetchedAt: number; + manifest: FeatureAnnouncementManifest | null; +}; + +class FeatureAnnouncementsService { + private cache: CachedManifest | null = null; + + async getCurrent(): Promise { + const manifest = await this.loadManifest(); + if (!manifest) return null; + + if (manifest.minAppVersion) { + const currentVersion = await resolveAppVersion(); + const min = semver.coerce(manifest.minAppVersion); + const current = semver.coerce(currentVersion); + if (min && current && semver.lt(current, min)) { + return null; + } + } + + return manifest; + } + + async preview(): Promise { + const content = await this.readManifestContent(); + if (!content) return null; + + try { + return parseFeatureAnnouncementManifestRaw(toml.parse(content)); + } catch (error) { + log.warn('[feature-announcements] Failed to parse preview manifest', error); + return null; + } + } + + private async loadManifest(options?: { + bypassCache?: boolean; + }): Promise { + if ( + !options?.bypassCache && + this.cache && + Date.now() - this.cache.fetchedAt < MANIFEST_CACHE_TTL_MS + ) { + return this.cache.manifest; + } + + const content = await this.readManifestContent(); + if (!content) { + this.cache = { fetchedAt: Date.now(), manifest: null }; + return null; + } + + try { + const parsed = parseFeatureAnnouncementManifest(toml.parse(content)); + this.cache = { fetchedAt: Date.now(), manifest: parsed }; + return parsed; + } catch (error) { + log.warn('[feature-announcements] Failed to parse manifest', error); + this.cache = { fetchedAt: Date.now(), manifest: null }; + return null; + } + } + + private async readManifestContent(): Promise { + if (import.meta.env.DEV) { + try { + const localPath = join(app.getAppPath(), FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME); + return await readFile(localPath, 'utf8'); + } catch (error) { + log.debug( + '[feature-announcements] Local manifest unavailable, falling back to remote', + error + ); + } + } + + try { + const response = await fetch(FEATURE_ANNOUNCEMENT_MANIFEST_URL, { + headers: { 'Cache-Control': 'no-cache' }, + }); + if (!response.ok) { + log.warn('[feature-announcements] Remote manifest request failed', { + status: response.status, + }); + return null; + } + return await response.text(); + } catch (error) { + log.warn('[feature-announcements] Remote manifest fetch failed', error); + return null; + } + } +} + +export const featureAnnouncementsService = new FeatureAnnouncementsService(); diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..4f7e324bed 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -7,6 +7,7 @@ import { automationsController } from './core/automations/controller'; import { browserController } from './core/browser/controller'; import { conversationController } from './core/conversations/controller'; import { editorBufferController } from './core/editor/controller'; +import { featureAnnouncementsController } from './core/feature-announcements/controller'; import { featurebaseController } from './core/featurebase/controller'; import { forgejoController } from './core/forgejo/controller'; import { filesController } from './core/fs/controller'; @@ -56,6 +57,7 @@ export const rpcRouter = createRPCRouter({ pty: ptyController, resourceMonitor: resourceMonitorController, asana: asanaController, + featureAnnouncements: featureAnnouncementsController, featurebase: featurebaseController, forgejo: forgejoController, github: githubController, diff --git a/apps/emdash-desktop/src/renderer/app/workspace.tsx b/apps/emdash-desktop/src/renderer/app/workspace.tsx index 50040ccee3..a33b4b806a 100644 --- a/apps/emdash-desktop/src/renderer/app/workspace.tsx +++ b/apps/emdash-desktop/src/renderer/app/workspace.tsx @@ -1,3 +1,4 @@ +import { FeatureAnnouncementCard } from '@renderer/features/feature-announcements/feature-announcement-card'; import { LeftSidebar } from '@renderer/features/sidebar/left-sidebar'; import { CommandShortcutBinder } from '@renderer/lib/commands/command-shortcut-binder'; import { AppKeyboardShortcuts } from '@renderer/lib/components/app-keyboard-shortcuts'; @@ -30,6 +31,7 @@ export function Workspace() { } /> + ); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx new file mode 100644 index 0000000000..96a075eaf6 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx @@ -0,0 +1,149 @@ +import { ArrowUpRight, X } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { AnimatePresence, motion } from 'motion/react'; +import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; +import { useNavigate } from '@renderer/lib/layout/navigation-provider'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; + +export const FeatureAnnouncementCard = observer(function FeatureAnnouncementCard() { + const { navigate } = useNavigate(); + const manifest = appState.featureAnnouncements.visibleManifest; + + const handleLearnMore = () => { + if (!manifest?.learnMoreUrl) return; + confirmOpenExternalLink(manifest.learnMoreUrl); + }; + + const handleChangelog = () => { + if (!manifest) return; + confirmOpenExternalLink(manifest.changelogUrl); + }; + + const handlePrimaryAction = () => { + if (!manifest?.cta) return; + + if (manifest.cta.url) { + confirmOpenExternalLink(manifest.cta.url); + appState.featureAnnouncements.dismiss(); + return; + } + + const view = appState.featureAnnouncements.resolveCtaView(manifest.cta.view); + if (view) { + navigate(view); + } + appState.featureAnnouncements.dismiss(); + }; + + return ( + + {manifest && ( + +
+
+ {manifest.image ? ( + + ) : ( +
+
+
+
+
+
+
+
+
+
+ )} + +
+ +
+
+

+ {manifest.eyebrow} +

+

{manifest.title}

+
+ +
    + {manifest.features.map((feature) => { + const Icon = getFeatureAnnouncementIcon(feature.icon); + return ( +
  • +
    + +
    +
    +

    {feature.title}

    +

    + {feature.description} +

    +
    +
  • + ); + })} +
+ +
+
+ {manifest.learnMoreUrl && ( + + )} + +
+ {manifest.cta && ( + + )} +
+
+
+ + )} + + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-icon.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-icon.tsx new file mode 100644 index 0000000000..c7d6329625 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-icon.tsx @@ -0,0 +1,23 @@ +import { + CalendarClock, + Check, + ListChecks, + MessageSquare, + Shield, + Sparkles, + type LucideIcon, +} from 'lucide-react'; +import type { FeatureAnnouncementIcon } from '@shared/feature-announcements/schema'; + +const ICONS: Record = { + 'calendar-clock': CalendarClock, + 'list-checks': ListChecks, + shield: Shield, + check: Check, + sparkles: Sparkles, + 'message-square': MessageSquare, +}; + +export function getFeatureAnnouncementIcon(icon: FeatureAnnouncementIcon): LucideIcon { + return ICONS[icon]; +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts new file mode 100644 index 0000000000..db8f3f1755 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FeatureAnnouncementStore } from '@renderer/features/feature-announcements/feature-announcement-store'; +import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +const manifest: FeatureAnnouncementManifest = { + enabled: true, + id: 'test-announcement', + eyebrow: 'Now available', + title: 'Test Feature', + changelogUrl: 'https://emdash.sh/changelog', + features: [ + { + icon: 'sparkles', + title: 'Something new', + description: 'A highlighted capability.', + }, + ], +}; + +describe('FeatureAnnouncementStore', () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal('localStorage', { + clear: vi.fn(() => storage.clear()), + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + removeItem: vi.fn((key: string) => storage.delete(key)), + }); + }); + + it('hides dismissed announcements', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.dismiss(); + + expect(store.visibleManifest).toBeNull(); + }); + + it('shows preview announcements even when dismissed', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.dismiss(); + store.preview(manifest); + + expect(store.visibleManifest).toEqual(manifest); + }); + + it('persists dismissed announcement ids', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.dismiss(); + + expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBe( + JSON.stringify(['test-announcement']) + ); + }); + + it('resolves known CTA views', () => { + const store = new FeatureAnnouncementStore(); + expect(store.resolveCtaView('automations')).toBe('automations'); + expect(store.resolveCtaView('unknown-view')).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts new file mode 100644 index 0000000000..0b0f3202ae --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -0,0 +1,125 @@ +import { action, computed, makeObservable, observable, runInAction } from 'mobx'; +import { + FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, + FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS, + type FeatureAnnouncementNavigableView, +} from '@shared/feature-announcements/constants'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +function readDismissedIds(): Set { + try { + const raw = localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === 'string')); + } catch { + return new Set(); + } +} + +function writeDismissedIds(ids: Set): void { + try { + localStorage.setItem( + FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, + JSON.stringify([...ids].sort()) + ); + } catch { + // localStorage may be unavailable + } +} + +function isNavigableView(value: string): value is FeatureAnnouncementNavigableView { + return (FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS as readonly string[]).includes(value); +} + +export class FeatureAnnouncementStore { + manifest: FeatureAnnouncementManifest | null = null; + dismissedIds = readDismissedIds(); + previewMode = false; + status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; + + constructor() { + makeObservable(this, { + manifest: observable, + dismissedIds: observable, + previewMode: observable, + status: observable, + visibleManifest: computed, + dismiss: action, + preview: action, + clearPreview: action, + setManifest: action, + setStatus: action, + }); + } + + get visibleManifest(): FeatureAnnouncementManifest | null { + if (!this.manifest) return null; + if (this.previewMode) return this.manifest; + if (this.dismissedIds.has(this.manifest.id)) return null; + return this.manifest; + } + + setManifest(manifest: FeatureAnnouncementManifest | null): void { + this.manifest = manifest; + } + + setStatus(status: FeatureAnnouncementStore['status']): void { + this.status = status; + } + + dismiss(): void { + if (!this.manifest) return; + this.dismissedIds = new Set([...this.dismissedIds, this.manifest.id]); + writeDismissedIds(this.dismissedIds); + this.previewMode = false; + } + + preview(manifest: FeatureAnnouncementManifest): void { + this.manifest = manifest; + this.previewMode = true; + } + + clearPreview(): void { + this.previewMode = false; + } + + async start(): Promise { + await this.refresh(); + } + + async refresh(options?: { preview?: boolean }): Promise { + const { rpc } = await import('@renderer/lib/ipc'); + + runInAction(() => { + this.status = 'loading'; + }); + + try { + const response = options?.preview + ? await rpc.featureAnnouncements.preview() + : await rpc.featureAnnouncements.getCurrent(); + + runInAction(() => { + if (!response?.success) { + this.status = 'error'; + return; + } + + this.manifest = response.data; + this.previewMode = Boolean(options?.preview && response.data); + this.status = 'ready'; + }); + } catch { + runInAction(() => { + this.status = 'error'; + }); + } + } + + resolveCtaView(view: string | undefined): FeatureAnnouncementNavigableView | null { + if (!view || !isNavigableView(view)) return null; + return view; + } +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx new file mode 100644 index 0000000000..01c8ad0af0 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx @@ -0,0 +1,30 @@ +import { Megaphone } from 'lucide-react'; +import React from 'react'; +import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; +import { SettingRow } from './SettingRow'; + +export function AnnouncementPreviewRow(): React.JSX.Element | null { + if (!import.meta.env.DEV) return null; + + return ( + { + void appState.featureAnnouncements.refresh({ preview: true }); + }} + > + + Preview + + } + /> + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 965e2ce02e..94d0ce87a0 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -4,6 +4,7 @@ import { PageContent, PageLayout, PageSidebarMenu } from '@renderer/lib/componen import { rpc } from '@renderer/lib/ipc'; import { AgentsSettingsPage } from '../agents-page/AgentsSettingsPage'; import { AccountTab } from './AccountTab'; +import { AnnouncementPreviewRow } from './AnnouncementPreviewRow'; import { BrowserSettingsCard } from './BrowserSettingsCard'; import HiddenToolsSettingsCard from './HiddenToolsSettingsCard'; import IntegrationsCard from './IntegrationsCard'; @@ -52,6 +53,7 @@ function GeneralSettingsPage() { description="Manage your account, privacy settings, notifications, and app updates." /> + diff --git a/apps/emdash-desktop/src/renderer/lib/stores/app-state.ts b/apps/emdash-desktop/src/renderer/lib/stores/app-state.ts index 8abd97a7d3..0ae4417dd5 100644 --- a/apps/emdash-desktop/src/renderer/lib/stores/app-state.ts +++ b/apps/emdash-desktop/src/renderer/lib/stores/app-state.ts @@ -1,3 +1,4 @@ +import { FeatureAnnouncementStore } from '@renderer/features/feature-announcements/feature-announcement-store'; import { ProjectManagerStore } from '@renderer/features/projects/stores/project-manager'; import { SidebarStore } from '@renderer/features/sidebar/sidebar-store'; import { NavigationHistoryStore } from './navigation-history-store'; @@ -16,10 +17,12 @@ class AppState { readonly navigation: NavigationStore; readonly sshConnections: SshConnectionStore; readonly resourceMonitor: ResourceMonitorStore; + readonly featureAnnouncements: FeatureAnnouncementStore; constructor() { this.snapshots = snapshotRegistry; this.update = new UpdateStore(); + this.featureAnnouncements = new FeatureAnnouncementStore(); this.projects = new ProjectManagerStore(); this.sidebar = new SidebarStore(this.projects); this.history = new NavigationHistoryStore(); diff --git a/apps/emdash-desktop/src/renderer/main.tsx b/apps/emdash-desktop/src/renderer/main.tsx index 0ff6507de9..38e76a871f 100644 --- a/apps/emdash-desktop/src/renderer/main.tsx +++ b/apps/emdash-desktop/src/renderer/main.tsx @@ -30,6 +30,7 @@ async function bootstrap() { wireExternalLinkRequests(); appState.update.start(); + void appState.featureAnnouncements.start(); initSoundPlayer(); // Initialize Monaco and load app data in parallel. Awaiting Monaco here diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts new file mode 100644 index 0000000000..72caff6864 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -0,0 +1,20 @@ +export const FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME = 'feature-announcements.toml'; + +export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = + 'https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/feature-announcements.toml'; + +export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; + +export const FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS = [ + 'home', + 'automations', + 'library', + 'skills', + 'mcp', + 'project', + 'task', + 'settings', +] as const; + +export type FeatureAnnouncementNavigableView = + (typeof FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS)[number]; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts new file mode 100644 index 0000000000..5e70ebc900 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + parseFeatureAnnouncementManifest, + parseFeatureAnnouncementManifestRaw, +} from '@shared/feature-announcements/schema'; + +const sampleManifest = { + enabled: true, + id: 'automations-2026-06', + eyebrow: 'Now available', + title: 'Emdash Automations', + changelogUrl: 'https://emdash.sh/changelog', + learnMoreUrl: 'https://docs.emdash.sh', + minAppVersion: '1.1.27', + features: [ + { + icon: 'calendar-clock', + title: 'Run agents on a schedule', + description: 'Launch coding agents on a cron.', + }, + ], + cta: { + label: 'Open Automations', + view: 'automations', + }, +}; + +describe('feature announcement schema', () => { + it('parses enabled manifests', () => { + expect(parseFeatureAnnouncementManifest(sampleManifest)).toEqual(sampleManifest); + }); + + it('returns null for disabled manifests', () => { + expect(parseFeatureAnnouncementManifest({ ...sampleManifest, enabled: false })).toBeNull(); + }); + + it('allows preview parsing when disabled', () => { + expect(parseFeatureAnnouncementManifestRaw({ ...sampleManifest, enabled: false })).toEqual({ + ...sampleManifest, + enabled: false, + }); + }); + + it('rejects invalid CTA definitions', () => { + expect( + parseFeatureAnnouncementManifest({ + ...sampleManifest, + cta: { label: 'Broken', view: 'automations', url: 'https://example.com' }, + }) + ).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts new file mode 100644 index 0000000000..8c52bc4560 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -0,0 +1,62 @@ +import z from 'zod'; +import { FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS } from './constants'; + +const featureAnnouncementIconSchema = z.enum([ + 'calendar-clock', + 'list-checks', + 'shield', + 'check', + 'sparkles', + 'message-square', +]); + +const featureAnnouncementViewSchema = z.enum(FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS); + +const featureAnnouncementFeatureSchema = z.object({ + icon: featureAnnouncementIconSchema, + title: z.string().min(1), + description: z.string().min(1), +}); + +const featureAnnouncementCtaSchema = z + .object({ + label: z.string().min(1), + view: featureAnnouncementViewSchema.optional(), + url: z.url().optional(), + }) + .refine((cta) => Boolean(cta.view) !== Boolean(cta.url), { + message: 'CTA must specify exactly one of view or url', + }); + +export const featureAnnouncementManifestSchema = z.object({ + enabled: z.boolean().default(false), + id: z.string().min(1), + eyebrow: z.string().min(1).default('Now available'), + title: z.string().min(1), + image: z.url().optional(), + changelogUrl: z.url(), + learnMoreUrl: z.url().optional(), + minAppVersion: z.string().min(1).optional(), + features: z.array(featureAnnouncementFeatureSchema).min(1).max(4), + cta: featureAnnouncementCtaSchema.optional(), +}); + +export type FeatureAnnouncementIcon = z.infer; +export type FeatureAnnouncementFeature = z.infer; +export type FeatureAnnouncementCta = z.infer; +export type FeatureAnnouncementManifest = z.infer; + +export function parseFeatureAnnouncementManifest(raw: unknown): FeatureAnnouncementManifest | null { + const parsed = featureAnnouncementManifestSchema.safeParse(raw); + if (!parsed.success) return null; + if (!parsed.data.enabled) return null; + return parsed.data; +} + +export function parseFeatureAnnouncementManifestRaw( + raw: unknown +): FeatureAnnouncementManifest | null { + const parsed = featureAnnouncementManifestSchema.safeParse(raw); + if (!parsed.success) return null; + return parsed.data; +} diff --git a/apps/emdash-desktop/src/shared/urls.ts b/apps/emdash-desktop/src/shared/urls.ts index 0674b3bdc9..31319ee0c8 100644 --- a/apps/emdash-desktop/src/shared/urls.ts +++ b/apps/emdash-desktop/src/shared/urls.ts @@ -1,4 +1,5 @@ export const EMDASH_RELEASES_URL = 'https://github.com/generalaction/emdash/releases'; +export const EMDASH_CHANGELOG_URL = 'https://emdash.sh/changelog'; export const EMDASH_DOCS_URL = 'https://docs.emdash.sh'; export const EMDASH_ISSUES_URL = 'https://github.com/generalaction/emdash/issues'; export const EMDASH_ISSUES_NEW_URL = 'https://github.com/generalaction/emdash/issues/new/choose'; From aad0104549984c33fc35156598473f35115223ed Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:26:36 +0200 Subject: [PATCH 02/21] feat: present announcements as toast --- .../emdash-desktop/feature-announcements.toml | 11 ++ apps/emdash-desktop/src/renderer/App.tsx | 4 + .../src/renderer/app/modal-registry.ts | 2 + .../src/renderer/app/workspace.tsx | 2 - .../feature-announcement-card.tsx | 149 ------------------ .../feature-announcement-launcher.tsx | 23 +++ .../feature-announcement-media.test.ts | 44 ++++++ .../feature-announcement-media.tsx | 97 ++++++++++++ .../feature-announcement-modal.tsx | 103 ++++++++++++ .../feature-announcement-state.test.ts | 65 ++++++++ .../feature-announcement-state.ts | 51 ++++++ .../feature-announcement-store.test.ts | 15 +- .../feature-announcement-store.ts | 98 ++++++------ .../feature-announcement-toast.tsx | 124 +++++++++++++++ .../present-feature-announcement.ts | 21 +++ .../components/AnnouncementPreviewRow.tsx | 30 ---- .../settings/components/SettingsPage.tsx | 2 - .../settings/components/UpdateCard.tsx | 40 ++++- apps/emdash-desktop/src/renderer/main.tsx | 5 +- .../feature-announcements/schema.test.ts | 1 + .../shared/feature-announcements/schema.ts | 9 ++ 21 files changed, 653 insertions(+), 243 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts delete mode 100644 apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml index 5d43bd6919..dbd8ce3a07 100644 --- a/apps/emdash-desktop/feature-announcements.toml +++ b/apps/emdash-desktop/feature-announcements.toml @@ -5,9 +5,20 @@ # for the next launch. # # The app fetches this file from the main branch at startup. +# +# Media — pick one (`image` wins if both are set): +# hero = built-in coded UI mock shipped with the app +# image = remote hero image URL, rendered full-bleed in the dark header area +# +# Examples: +# hero = "automations" +# image = "https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/build/feature-announcements/automations-hero.png" enabled = true id = "automations-2026-06" +display = "toast" +hero = "automations" +# image = "https://example.com/automations-hero.png" eyebrow = "Now available" title = "Emdash Automations" changelogUrl = "https://emdash.sh/changelog" diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index 551a265f97..fcacbff576 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; +import { FeatureAnnouncementLauncher } from './features/feature-announcements/feature-announcement-launcher'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -91,6 +92,8 @@ function AppContent() { return ; }; + const workspaceVisible = !isLoading && view === 'workspace'; + return ( @@ -99,6 +102,7 @@ function AppContent() { + diff --git a/apps/emdash-desktop/src/renderer/app/modal-registry.ts b/apps/emdash-desktop/src/renderer/app/modal-registry.ts index fd2bee9e1e..bebb9232cb 100644 --- a/apps/emdash-desktop/src/renderer/app/modal-registry.ts +++ b/apps/emdash-desktop/src/renderer/app/modal-registry.ts @@ -1,4 +1,5 @@ import { CommandPaletteModal } from '@renderer/features/command-palette/command-palette-modal'; +import { FeatureAnnouncementModal } from '@renderer/features/feature-announcements/feature-announcement-modal'; import { IntegrationSetupModal } from '@renderer/features/integrations/integration-setup-modal'; import { PromptModal } from '@renderer/features/library/prompts/prompt-modal'; import { McpModal } from '@renderer/features/mcp/components/McpModal'; @@ -40,6 +41,7 @@ export function createModal( } export const modalRegistry = { + featureAnnouncementModal: createModal(FeatureAnnouncementModal, { size: 'md' }), commandPaletteModal: createModal(CommandPaletteModal, { size: 'md' }), taskModal: createModal(CreateTaskModal), addProjectModal: createModal(AddProjectModal), diff --git a/apps/emdash-desktop/src/renderer/app/workspace.tsx b/apps/emdash-desktop/src/renderer/app/workspace.tsx index a33b4b806a..50040ccee3 100644 --- a/apps/emdash-desktop/src/renderer/app/workspace.tsx +++ b/apps/emdash-desktop/src/renderer/app/workspace.tsx @@ -1,4 +1,3 @@ -import { FeatureAnnouncementCard } from '@renderer/features/feature-announcements/feature-announcement-card'; import { LeftSidebar } from '@renderer/features/sidebar/left-sidebar'; import { CommandShortcutBinder } from '@renderer/lib/commands/command-shortcut-binder'; import { AppKeyboardShortcuts } from '@renderer/lib/components/app-keyboard-shortcuts'; @@ -31,7 +30,6 @@ export function Workspace() { } /> - ); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx deleted file mode 100644 index 96a075eaf6..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-card.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { ArrowUpRight, X } from 'lucide-react'; -import { observer } from 'mobx-react-lite'; -import { AnimatePresence, motion } from 'motion/react'; -import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; -import { useNavigate } from '@renderer/lib/layout/navigation-provider'; -import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; -import { appState } from '@renderer/lib/stores/app-state'; -import { Button } from '@renderer/lib/ui/button'; -import { cn } from '@renderer/utils/utils'; - -export const FeatureAnnouncementCard = observer(function FeatureAnnouncementCard() { - const { navigate } = useNavigate(); - const manifest = appState.featureAnnouncements.visibleManifest; - - const handleLearnMore = () => { - if (!manifest?.learnMoreUrl) return; - confirmOpenExternalLink(manifest.learnMoreUrl); - }; - - const handleChangelog = () => { - if (!manifest) return; - confirmOpenExternalLink(manifest.changelogUrl); - }; - - const handlePrimaryAction = () => { - if (!manifest?.cta) return; - - if (manifest.cta.url) { - confirmOpenExternalLink(manifest.cta.url); - appState.featureAnnouncements.dismiss(); - return; - } - - const view = appState.featureAnnouncements.resolveCtaView(manifest.cta.view); - if (view) { - navigate(view); - } - appState.featureAnnouncements.dismiss(); - }; - - return ( - - {manifest && ( - -
-
- {manifest.image ? ( - - ) : ( -
-
-
-
-
-
-
-
-
-
- )} - -
- -
-
-

- {manifest.eyebrow} -

-

{manifest.title}

-
- -
    - {manifest.features.map((feature) => { - const Icon = getFeatureAnnouncementIcon(feature.icon); - return ( -
  • -
    - -
    -
    -

    {feature.title}

    -

    - {feature.description} -

    -
    -
  • - ); - })} -
- -
-
- {manifest.learnMoreUrl && ( - - )} - -
- {manifest.cta && ( - - )} -
-
-
- - )} - - ); -}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx new file mode 100644 index 0000000000..f4442cf149 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx @@ -0,0 +1,23 @@ +import { observer } from 'mobx-react-lite'; +import { useEffect } from 'react'; +import { appState } from '@renderer/lib/stores/app-state'; + +export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncementLauncher({ + active, +}: { + active: boolean; +}) { + const store = appState.featureAnnouncements; + + useEffect(() => { + if (!active || store.status !== 'ready' || !store.shouldPresent) return; + + const timer = setTimeout(() => { + store.present(); + }, 800); + + return () => clearTimeout(timer); + }, [active, store, store.status, store.shouldPresent]); + + return null; +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts new file mode 100644 index 0000000000..c9c39dabca --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { resolveFeatureAnnouncementMedia } from '@renderer/features/feature-announcements/feature-announcement-media'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +const baseManifest: FeatureAnnouncementManifest = { + enabled: true, + id: 'test', + display: 'toast', + eyebrow: 'Now available', + title: 'Test', + changelogUrl: 'https://emdash.sh/changelog', + features: [{ icon: 'sparkles', title: 'Feature', description: 'Description.' }], +}; + +describe('resolveFeatureAnnouncementMedia', () => { + it('prefers remote image URLs over built-in hero components', () => { + expect( + resolveFeatureAnnouncementMedia({ + ...baseManifest, + hero: 'automations', + image: 'https://emdash.sh/media/automations-hero.png', + }) + ).toEqual({ + kind: 'image', + url: 'https://emdash.sh/media/automations-hero.png', + }); + }); + + it('falls back to coded hero components when no image is set', () => { + expect( + resolveFeatureAnnouncementMedia({ + ...baseManifest, + hero: 'automations', + }) + ).toEqual({ + kind: 'hero', + hero: 'automations', + }); + }); + + it('returns null when neither image nor hero is configured', () => { + expect(resolveFeatureAnnouncementMedia(baseManifest)).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx new file mode 100644 index 0000000000..24d7d02be7 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react'; +import { cn } from '@renderer/utils/utils'; +import type { + FeatureAnnouncementHero, + FeatureAnnouncementManifest, +} from '@shared/feature-announcements/schema'; + +function AutomationsHeroGraphic() { + return ( +
+
+ Nightly dependency audit + +
+
+ Triage new issues + daily 9:00 +
+
+ Weekly changelog draft + Mon 8:00 +
+
+ ); +} + +const HERO_COMPONENTS: Record ReactNode> = { + automations: AutomationsHeroGraphic, +}; + +export type FeatureAnnouncementMedia = + | { kind: 'image'; url: string } + | { kind: 'hero'; hero: FeatureAnnouncementHero }; + +/** + * Resolves the announcement media area. Remote `image` URLs take precedence over + * built-in coded `hero` components shipped with the app. + */ +export function resolveFeatureAnnouncementMedia( + manifest: FeatureAnnouncementManifest +): FeatureAnnouncementMedia | null { + if (manifest.image) { + return { kind: 'image', url: manifest.image }; + } + + if (manifest.hero) { + return { kind: 'hero', hero: manifest.hero }; + } + + return null; +} + +export function FeatureAnnouncementMediaArea({ + media, + variant, +}: { + media: FeatureAnnouncementMedia; + variant: 'toast' | 'modal'; +}) { + const heightClass = variant === 'toast' ? 'h-28' : 'h-52'; + + if (media.kind === 'image') { + return ( +
+ +
+
+ ); + } + + const HeroGraphic = HERO_COMPONENTS[media.hero]; + if (!HeroGraphic) return null; + + return ( +
+
+
+ +
+
+ ); +} + +export function getFeatureAnnouncementMedia( + manifest: FeatureAnnouncementManifest +): FeatureAnnouncementMedia | null { + return resolveFeatureAnnouncementMedia(manifest); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx new file mode 100644 index 0000000000..6e43ad514f --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx @@ -0,0 +1,103 @@ +import { ArrowUpRight, XIcon } from 'lucide-react'; +import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; +import { + FeatureAnnouncementMediaArea, + getFeatureAnnouncementMedia, +} from '@renderer/features/feature-announcements/feature-announcement-media'; +import { type BaseModalProps } from '@renderer/lib/modal/modal-provider'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; +import { DialogFooter, DialogTitle } from '@renderer/lib/ui/dialog'; +import { cn } from '@renderer/utils/utils'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +export function FeatureAnnouncementModal({ + manifest, + onSuccess, + onClose, +}: { manifest: FeatureAnnouncementManifest } & BaseModalProps) { + const media = getFeatureAnnouncementMedia(manifest); + + const handleLearnMore = () => { + if (manifest.learnMoreUrl) confirmOpenExternalLink(manifest.learnMoreUrl); + }; + + const handleChangelog = () => { + confirmOpenExternalLink(manifest.changelogUrl); + }; + + const handleCta = () => { + if (manifest.cta?.url) { + confirmOpenExternalLink(manifest.cta.url); + onSuccess(); + return; + } + + const view = appState.featureAnnouncements.resolveCtaView(manifest.cta?.view); + if (view) { + appState.navigation.navigate(view); + } + onSuccess(); + }; + + return ( + <> + {media && } + +
+
+

{manifest.eyebrow}

+ + {manifest.title} + +
+
    + {manifest.features.map((feature) => { + const Icon = getFeatureAnnouncementIcon(feature.icon); + return ( +
  • + +
    +

    {feature.title}

    +

    {feature.description}

    +
    +
  • + ); + })} +
+
+ +
+ {manifest.learnMoreUrl && ( + + )} + +
+ {manifest.cta ? ( + + ) : ( + + )} +
+ + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts new file mode 100644 index 0000000000..022bc90ccd --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + initializeFreshInstallAnnouncement, + isAnnouncementDismissed, + markAnnouncementDismissed, + readDismissedIds, +} from '@renderer/features/feature-announcements/feature-announcement-state'; +import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; + +function makeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => void data.set(key, value), + dump: () => Object.fromEntries(data), + }; +} + +describe('feature announcement state', () => { + it('marks the current announcement as dismissed on a fresh install', () => { + const storage = makeStorage(); + initializeFreshInstallAnnouncement({ + announcementId: 'automations-2026-06', + isFreshInstall: true, + storage, + }); + expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(true); + }); + + it('does not touch already-initialized state on a fresh-install flag', () => { + const storage = makeStorage({ + [FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY]: JSON.stringify(['older']), + }); + initializeFreshInstallAnnouncement({ + announcementId: 'automations-2026-06', + isFreshInstall: true, + storage, + }); + expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(false); + }); + + it('shows existing users unseen announcements', () => { + const storage = makeStorage(); + initializeFreshInstallAnnouncement({ + announcementId: 'automations-2026-06', + isFreshInstall: false, + storage, + }); + expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(false); + }); + + it('is idempotent when marking the same announcement twice', () => { + const storage = makeStorage(); + markAnnouncementDismissed('newer', storage); + markAnnouncementDismissed('newer', storage); + expect(JSON.parse(storage.dump()[FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY])).toEqual([ + 'newer', + ]); + }); + + it('treats corrupted storage as empty dismissed state', () => { + const storage = makeStorage({ [FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY]: 'not json{' }); + expect(readDismissedIds(storage)).toEqual(new Set()); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts new file mode 100644 index 0000000000..a14bf7aac2 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts @@ -0,0 +1,51 @@ +import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; + +type StorageLike = Pick; + +/** Returns null when announcement tracking has never been initialized. */ +export function readDismissedIds(storage: StorageLike = localStorage): Set | null { + try { + const raw = storage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === 'string')); + } catch { + return new Set(); + } +} + +export function writeDismissedIds(ids: Set, storage: StorageLike = localStorage): void { + try { + storage.setItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, JSON.stringify([...ids].sort())); + } catch { + // localStorage may be unavailable + } +} + +/** + * Fresh installs shouldn't greet new users with a backlog of announcements. + * Mark the current manifest as dismissed on first launch without showing it. + */ +export function initializeFreshInstallAnnouncement(options: { + announcementId: string; + isFreshInstall: boolean; + storage?: StorageLike; +}): void { + const storage = options.storage ?? localStorage; + if (!options.isFreshInstall) return; + if (readDismissedIds(storage) !== null) return; + writeDismissedIds(new Set([options.announcementId]), storage); +} + +export function markAnnouncementDismissed(id: string, storage: StorageLike = localStorage): void { + const dismissed = readDismissedIds(storage) ?? new Set(); + if (dismissed.has(id)) return; + writeDismissedIds(new Set([...dismissed, id]), storage); +} + +export function isAnnouncementDismissed(id: string, storage: StorageLike = localStorage): boolean { + const dismissed = readDismissedIds(storage); + if (dismissed === null) return false; + return dismissed.has(id); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index db8f3f1755..e9cec07e20 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -6,6 +6,7 @@ import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/ const manifest: FeatureAnnouncementManifest = { enabled: true, id: 'test-announcement', + display: 'toast', eyebrow: 'Now available', title: 'Test Feature', changelogUrl: 'https://emdash.sh/changelog', @@ -29,27 +30,25 @@ describe('FeatureAnnouncementStore', () => { }); }); - it('hides dismissed announcements', () => { + it('does not present dismissed announcements', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.dismiss(); + store.dismissForTest(manifest.id); - expect(store.visibleManifest).toBeNull(); + expect(store.shouldPresent).toBe(false); }); - it('shows preview announcements even when dismissed', () => { + it('presents unseen announcements', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.dismiss(); - store.preview(manifest); - expect(store.visibleManifest).toEqual(manifest); + expect(store.shouldPresent).toBe(true); }); it('persists dismissed announcement ids', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.dismiss(); + store.dismissForTest(manifest.id); expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBe( JSON.stringify(['test-announcement']) diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index 0b0f3202ae..40d2bffd85 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -1,64 +1,38 @@ import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { - FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, + initializeFreshInstallAnnouncement, + isAnnouncementDismissed, + markAnnouncementDismissed, +} from '@renderer/features/feature-announcements/feature-announcement-state'; +import { FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS, type FeatureAnnouncementNavigableView, } from '@shared/feature-announcements/constants'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; -function readDismissedIds(): Set { - try { - const raw = localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY); - if (!raw) return new Set(); - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return new Set(); - return new Set(parsed.filter((value): value is string => typeof value === 'string')); - } catch { - return new Set(); - } -} - -function writeDismissedIds(ids: Set): void { - try { - localStorage.setItem( - FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, - JSON.stringify([...ids].sort()) - ); - } catch { - // localStorage may be unavailable - } -} - function isNavigableView(value: string): value is FeatureAnnouncementNavigableView { return (FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS as readonly string[]).includes(value); } export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; - dismissedIds = readDismissedIds(); - previewMode = false; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; + private hasPresented = false; constructor() { makeObservable(this, { manifest: observable, - dismissedIds: observable, - previewMode: observable, status: observable, - visibleManifest: computed, - dismiss: action, - preview: action, - clearPreview: action, + shouldPresent: computed, + markPresented: action, setManifest: action, setStatus: action, }); } - get visibleManifest(): FeatureAnnouncementManifest | null { - if (!this.manifest) return null; - if (this.previewMode) return this.manifest; - if (this.dismissedIds.has(this.manifest.id)) return null; - return this.manifest; + get shouldPresent(): boolean { + if (!this.manifest || this.hasPresented) return false; + return !isAnnouncementDismissed(this.manifest.id); } setManifest(manifest: FeatureAnnouncementManifest | null): void { @@ -69,24 +43,43 @@ export class FeatureAnnouncementStore { this.status = status; } - dismiss(): void { - if (!this.manifest) return; - this.dismissedIds = new Set([...this.dismissedIds, this.manifest.id]); - writeDismissedIds(this.dismissedIds); - this.previewMode = false; + markPresented(): void { + this.hasPresented = true; } - preview(manifest: FeatureAnnouncementManifest): void { - this.manifest = manifest; - this.previewMode = true; - } + present(options?: { preview?: boolean; display?: 'modal' | 'toast' }): void { + if (!this.manifest) return; - clearPreview(): void { - this.previewMode = false; + const manifest = + options?.display !== undefined + ? { ...this.manifest, display: options.display } + : this.manifest; + + void import('@renderer/features/feature-announcements/present-feature-announcement').then( + ({ presentFeatureAnnouncement }) => { + presentFeatureAnnouncement(manifest, () => { + if (options?.preview) { + this.hasPresented = false; + } + }); + } + ); + + this.markPresented(); + if (!options?.preview) { + markAnnouncementDismissed(this.manifest.id); + } } - async start(): Promise { + async start(options?: { isFreshInstall?: boolean }): Promise { await this.refresh(); + + if (this.manifest && options?.isFreshInstall) { + initializeFreshInstallAnnouncement({ + announcementId: this.manifest.id, + isFreshInstall: true, + }); + } } async refresh(options?: { preview?: boolean }): Promise { @@ -94,6 +87,7 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; + this.hasPresented = false; }); try { @@ -108,7 +102,6 @@ export class FeatureAnnouncementStore { } this.manifest = response.data; - this.previewMode = Boolean(options?.preview && response.data); this.status = 'ready'; }); } catch { @@ -122,4 +115,9 @@ export class FeatureAnnouncementStore { if (!view || !isNavigableView(view)) return null; return view; } + + /** @internal test helper */ + dismissForTest(id: string): void { + markAnnouncementDismissed(id); + } } diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx new file mode 100644 index 0000000000..e71a735d0f --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -0,0 +1,124 @@ +import { ArrowUpRight, XIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; +import { + FeatureAnnouncementMediaArea, + getFeatureAnnouncementMedia, +} from '@renderer/features/feature-announcements/feature-announcement-media'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +export function showFeatureAnnouncementToast( + manifest: FeatureAnnouncementManifest, + onDismiss?: () => void +): void { + toast.custom( + (id) => , + { + duration: Infinity, + } + ); +} + +function FeatureAnnouncementToastCard({ + manifest, + toastId, + onDismiss, +}: { + manifest: FeatureAnnouncementManifest; + toastId: string | number; + onDismiss?: () => void; +}) { + const media = getFeatureAnnouncementMedia(manifest); + const dismiss = () => { + toast.dismiss(toastId); + onDismiss?.(); + }; + + const handleLearnMore = () => { + if (manifest.learnMoreUrl) confirmOpenExternalLink(manifest.learnMoreUrl); + }; + + const handleChangelog = () => { + confirmOpenExternalLink(manifest.changelogUrl); + }; + + const handleCta = () => { + if (manifest.cta?.url) { + confirmOpenExternalLink(manifest.cta.url); + dismiss(); + return; + } + + const view = appState.featureAnnouncements.resolveCtaView(manifest.cta?.view); + if (view) { + appState.navigation.navigate(view); + } + dismiss(); + }; + + return ( +
+ {media && } + +
+
+

{manifest.eyebrow}

+

{manifest.title}

+
+
    + {manifest.features.map((feature) => { + const Icon = getFeatureAnnouncementIcon(feature.icon); + return ( +
  • + +
    +

    {feature.title}

    +

    {feature.description}

    +
    +
  • + ); + })} +
+
+
+
+ {manifest.learnMoreUrl && ( + + )} + +
+ {manifest.cta ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts new file mode 100644 index 0000000000..0ecfffa784 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts @@ -0,0 +1,21 @@ +import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; +import { showModal } from '@renderer/lib/modal/modal-provider'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +export function presentFeatureAnnouncement( + manifest: FeatureAnnouncementManifest, + onDismiss?: () => void +): void { + const display = manifest.display ?? 'toast'; + + if (display === 'modal') { + showModal('featureAnnouncementModal', { + manifest, + onSuccess: () => onDismiss?.(), + onClose: () => onDismiss?.(), + }); + return; + } + + showFeatureAnnouncementToast(manifest, onDismiss); +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx deleted file mode 100644 index 01c8ad0af0..0000000000 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementPreviewRow.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Megaphone } from 'lucide-react'; -import React from 'react'; -import { appState } from '@renderer/lib/stores/app-state'; -import { Button } from '@renderer/lib/ui/button'; -import { SettingRow } from './SettingRow'; - -export function AnnouncementPreviewRow(): React.JSX.Element | null { - if (!import.meta.env.DEV) return null; - - return ( - { - void appState.featureAnnouncements.refresh({ preview: true }); - }} - > - - Preview - - } - /> - ); -} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 94d0ce87a0..965e2ce02e 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -4,7 +4,6 @@ import { PageContent, PageLayout, PageSidebarMenu } from '@renderer/lib/componen import { rpc } from '@renderer/lib/ipc'; import { AgentsSettingsPage } from '../agents-page/AgentsSettingsPage'; import { AccountTab } from './AccountTab'; -import { AnnouncementPreviewRow } from './AnnouncementPreviewRow'; import { BrowserSettingsCard } from './BrowserSettingsCard'; import HiddenToolsSettingsCard from './HiddenToolsSettingsCard'; import IntegrationsCard from './IntegrationsCard'; @@ -53,7 +52,6 @@ function GeneralSettingsPage() { description="Manage your account, privacy settings, notifications, and app updates." /> - diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx index c5f8f97b6e..138a9d69b3 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw } from 'lucide-react'; +import { AlertCircle, CheckCircle2, Download, Loader2, Megaphone, RefreshCw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; import { appState } from '@renderer/lib/stores/app-state'; @@ -58,6 +58,44 @@ export const UpdateCard = observer(function UpdateCard(): React.JSX.Element { />
)} + + {import.meta.env.DEV && ( + + + +
+ } + /> + )}
); diff --git a/apps/emdash-desktop/src/renderer/main.tsx b/apps/emdash-desktop/src/renderer/main.tsx index 38e76a871f..c1f57f829b 100644 --- a/apps/emdash-desktop/src/renderer/main.tsx +++ b/apps/emdash-desktop/src/renderer/main.tsx @@ -19,6 +19,7 @@ import { log } from '@renderer/utils/logger'; import { initSoundPlayer } from '@renderer/utils/soundPlayer'; import type { NavigationSnapshot, SidebarSnapshot } from '@shared/view-state'; import { App } from './App'; +import { HAS_SEEN_ONBOARDING } from './App'; import { ErrorBoundary } from './lib/components/error-boundary'; import { appState } from './lib/stores/app-state'; @@ -30,7 +31,9 @@ async function bootstrap() { wireExternalLinkRequests(); appState.update.start(); - void appState.featureAnnouncements.start(); + void appState.featureAnnouncements.start({ + isFreshInstall: localStorage.getItem(HAS_SEEN_ONBOARDING) !== 'true', + }); initSoundPlayer(); // Initialize Monaco and load app data in parallel. Awaiting Monaco here diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts index 5e70ebc900..2773a0d1cb 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -7,6 +7,7 @@ import { const sampleManifest = { enabled: true, id: 'automations-2026-06', + display: 'toast' as const, eyebrow: 'Now available', title: 'Emdash Automations', changelogUrl: 'https://emdash.sh/changelog', diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 8c52bc4560..83d5988db6 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -12,6 +12,10 @@ const featureAnnouncementIconSchema = z.enum([ const featureAnnouncementViewSchema = z.enum(FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS); +const featureAnnouncementHeroSchema = z.enum(['automations']); + +const featureAnnouncementDisplaySchema = z.enum(['modal', 'toast']); + const featureAnnouncementFeatureSchema = z.object({ icon: featureAnnouncementIconSchema, title: z.string().min(1), @@ -31,8 +35,10 @@ const featureAnnouncementCtaSchema = z export const featureAnnouncementManifestSchema = z.object({ enabled: z.boolean().default(false), id: z.string().min(1), + display: featureAnnouncementDisplaySchema.default('toast'), eyebrow: z.string().min(1).default('Now available'), title: z.string().min(1), + hero: featureAnnouncementHeroSchema.optional(), image: z.url().optional(), changelogUrl: z.url(), learnMoreUrl: z.url().optional(), @@ -41,6 +47,9 @@ export const featureAnnouncementManifestSchema = z.object({ cta: featureAnnouncementCtaSchema.optional(), }); +export type FeatureAnnouncementHero = z.infer; +export type FeatureAnnouncementDisplay = z.infer; + export type FeatureAnnouncementIcon = z.infer; export type FeatureAnnouncementFeature = z.infer; export type FeatureAnnouncementCta = z.infer; From e7b5e43378033484f9453abe13264ebfdc5a9cf9 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:56:09 +0200 Subject: [PATCH 03/21] style: polish announcement presentation --- .../feature-announcement-media.tsx | 34 +++++++++-------- .../feature-announcement-modal.tsx | 29 +++++---------- .../feature-announcement-toast.tsx | 37 +++++++++---------- 3 files changed, 46 insertions(+), 54 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx index 24d7d02be7..22b615f233 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx @@ -5,9 +5,13 @@ import type { FeatureAnnouncementManifest, } from '@shared/feature-announcements/schema'; +/** Applied when hovering the "Learn more" footer link. */ +export const FEATURE_ANNOUNCEMENT_LEARN_MORE_HOVER_CLASSES = + 'group-has-[.announcement-learn-more:hover]/announcement:-translate-y-1 group-has-[.announcement-learn-more:hover]/announcement:scale-[1.03]'; + function AutomationsHeroGraphic() { return ( -
+
Nightly dependency audit @@ -32,10 +36,6 @@ export type FeatureAnnouncementMedia = | { kind: 'image'; url: string } | { kind: 'hero'; hero: FeatureAnnouncementHero }; -/** - * Resolves the announcement media area. Remote `image` URLs take precedence over - * built-in coded `hero` components shipped with the app. - */ export function resolveFeatureAnnouncementMedia( manifest: FeatureAnnouncementManifest ): FeatureAnnouncementMedia | null { @@ -58,16 +58,20 @@ export function FeatureAnnouncementMediaArea({ variant: 'toast' | 'modal'; }) { const heightClass = variant === 'toast' ? 'h-28' : 'h-52'; + const motionClass = cn( + 'transition-transform duration-300 ease-out will-change-transform', + FEATURE_ANNOUNCEMENT_LEARN_MORE_HOVER_CLASSES + ); if (media.kind === 'image') { return ( -
- -
+
+
+ +
+
); } @@ -78,12 +82,12 @@ export function FeatureAnnouncementMediaArea({ return (
-
-
+
+
diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx index 6e43ad514f..becd04986d 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx @@ -20,11 +20,8 @@ export function FeatureAnnouncementModal({ const media = getFeatureAnnouncementMedia(manifest); const handleLearnMore = () => { - if (manifest.learnMoreUrl) confirmOpenExternalLink(manifest.learnMoreUrl); - }; - - const handleChangelog = () => { - confirmOpenExternalLink(manifest.changelogUrl); + const url = manifest.learnMoreUrl ?? manifest.changelogUrl; + confirmOpenExternalLink(url); }; const handleCta = () => { @@ -42,13 +39,13 @@ export function FeatureAnnouncementModal({ }; return ( - <> +
{media && }
-
- {manifest.learnMoreUrl && ( - - )} - -
+ {manifest.cta ? ( ) : ( )}
- +
); } diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index e71a735d0f..3a8f418442 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -11,6 +11,10 @@ import { Button } from '@renderer/lib/ui/button'; import { cn } from '@renderer/utils/utils'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; +const CUSTOM_TOAST_CLASSNAMES = { + toast: '!border-none !bg-transparent !p-0 !shadow-none', +}; + export function showFeatureAnnouncementToast( manifest: FeatureAnnouncementManifest, onDismiss?: () => void @@ -19,6 +23,7 @@ export function showFeatureAnnouncementToast( (id) => , { duration: Infinity, + classNames: CUSTOM_TOAST_CLASSNAMES, } ); } @@ -39,11 +44,8 @@ function FeatureAnnouncementToastCard({ }; const handleLearnMore = () => { - if (manifest.learnMoreUrl) confirmOpenExternalLink(manifest.learnMoreUrl); - }; - - const handleChangelog = () => { - confirmOpenExternalLink(manifest.changelogUrl); + const url = manifest.learnMoreUrl ?? manifest.changelogUrl; + confirmOpenExternalLink(url); }; const handleCta = () => { @@ -61,13 +63,13 @@ function FeatureAnnouncementToastCard({ }; return ( -
+
{media && }
-
- {manifest.learnMoreUrl && ( - - )} - -
+ {manifest.cta ? ( + + +
+ } + /> +
+ ); + } +); diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx index 138a9d69b3..a829499893 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx @@ -1,10 +1,11 @@ -import { AlertCircle, CheckCircle2, Download, Loader2, Megaphone, RefreshCw } from 'lucide-react'; +import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; import { appState } from '@renderer/lib/stores/app-state'; import { Badge } from '@renderer/lib/ui/badge'; import { Button } from '@renderer/lib/ui/button'; import { PRODUCT_NAME } from '@shared/app-identity'; +import { AnnouncementDevControls } from './AnnouncementDevControls'; import { SettingRow } from './SettingRow'; export const UpdateCard = observer(function UpdateCard(): React.JSX.Element { @@ -59,43 +60,7 @@ export const UpdateCard = observer(function UpdateCard(): React.JSX.Element {
)} - {import.meta.env.DEV && ( - - - -
- } - /> - )} +
); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index 72caff6864..dd44f5415f 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -5,6 +5,10 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; +/** Dev-only: show the announcement on every workspace launch, ignoring dismissal. */ +export const FEATURE_ANNOUNCEMENT_DEV_REPEAT_ON_LAUNCH_KEY = + 'emdash:dev:feature-announcements:repeat-on-launch'; + export const FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS = [ 'home', 'automations', From c50afd1e7c9a509feb5f933a7b1801e77d700713 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:01:34 +0200 Subject: [PATCH 05/21] style: simplify announcement hover states --- .../feature-announcement-media.tsx | 18 ++++++------------ .../feature-announcement-modal.tsx | 6 +++--- .../feature-announcement-toast.tsx | 11 +++-------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx index 22b615f233..9431233583 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx @@ -5,10 +5,6 @@ import type { FeatureAnnouncementManifest, } from '@shared/feature-announcements/schema'; -/** Applied when hovering the "Learn more" footer link. */ -export const FEATURE_ANNOUNCEMENT_LEARN_MORE_HOVER_CLASSES = - 'group-has-[.announcement-learn-more:hover]/announcement:-translate-y-1 group-has-[.announcement-learn-more:hover]/announcement:scale-[1.03]'; - function AutomationsHeroGraphic() { return (
@@ -58,19 +54,17 @@ export function FeatureAnnouncementMediaArea({ variant: 'toast' | 'modal'; }) { const heightClass = variant === 'toast' ? 'h-28' : 'h-52'; - const motionClass = cn( - 'transition-transform duration-300 ease-out will-change-transform', - FEATURE_ANNOUNCEMENT_LEARN_MORE_HOVER_CLASSES - ); if (media.kind === 'image') { return (
-
- -
+
); @@ -87,7 +81,7 @@ export function FeatureAnnouncementMediaArea({ )} >
-
+
diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx index becd04986d..f38a88ca9e 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-modal.tsx @@ -39,7 +39,7 @@ export function FeatureAnnouncementModal({ }; return ( -
+
{media && }
- {manifest.cta ? ( diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index 3a8f418442..32ec00b2a8 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -63,7 +63,7 @@ function FeatureAnnouncementToastCard({ }; return ( -
+
{media && }
- {manifest.cta ? ( -
-
-

{manifest.eyebrow}

- - {manifest.title} - -
-
    - {manifest.features.map((feature) => { - const Icon = getFeatureAnnouncementIcon(feature.icon); - return ( -
  • - -
    -

    {feature.title}

    -

    {feature.description}

    -
    -
  • - ); - })} -
-
- - - {manifest.cta ? ( - - ) : ( - - )} - -
- ); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index e9cec07e20..bb6c4534ca 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -6,7 +6,6 @@ import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/ const manifest: FeatureAnnouncementManifest = { enabled: true, id: 'test-announcement', - display: 'toast', eyebrow: 'Now available', title: 'Test Feature', changelogUrl: 'https://emdash.sh/changelog', diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index c6243b7727..d15647786d 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -6,7 +6,6 @@ import { markAnnouncementDismissed, } from '@renderer/features/feature-announcements/feature-announcement-state'; import { - FEATURE_ANNOUNCEMENT_DEV_REPEAT_ON_LAUNCH_KEY, FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS, type FeatureAnnouncementNavigableView, } from '@shared/feature-announcements/constants'; @@ -16,30 +15,18 @@ function isNavigableView(value: string): value is FeatureAnnouncementNavigableVi return (FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS as readonly string[]).includes(value); } -function readDevRepeatOnLaunch(): boolean { - if (!import.meta.env.DEV) return false; - try { - return localStorage.getItem(FEATURE_ANNOUNCEMENT_DEV_REPEAT_ON_LAUNCH_KEY) === 'true'; - } catch { - return false; - } -} - export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; - devRepeatOnLaunch = readDevRepeatOnLaunch(); private hasPresented = false; constructor() { makeObservable(this, { manifest: observable, status: observable, - devRepeatOnLaunch: observable, shouldPresent: computed, markPresented: action, resetPresentation: action, - setDevRepeatOnLaunch: action, setManifest: action, setStatus: action, }); @@ -47,7 +34,6 @@ export class FeatureAnnouncementStore { get shouldPresent(): boolean { if (!this.manifest || this.hasPresented) return false; - if (import.meta.env.DEV && this.devRepeatOnLaunch) return true; return !isAnnouncementDismissed(this.manifest.id); } @@ -59,22 +45,6 @@ export class FeatureAnnouncementStore { this.status = status; } - setDevRepeatOnLaunch(enabled: boolean): void { - if (!import.meta.env.DEV) return; - this.devRepeatOnLaunch = enabled; - try { - localStorage.setItem( - FEATURE_ANNOUNCEMENT_DEV_REPEAT_ON_LAUNCH_KEY, - enabled ? 'true' : 'false' - ); - } catch { - // localStorage may be unavailable - } - if (enabled) { - this.resetPresentation(); - } - } - resetPresentation(): void { this.hasPresented = false; } @@ -83,17 +53,12 @@ export class FeatureAnnouncementStore { this.hasPresented = true; } - present(options?: { preview?: boolean; display?: 'modal' | 'toast' }): void { + present(options?: { preview?: boolean }): void { if (!this.manifest) return; - const manifest = - options?.display !== undefined - ? { ...this.manifest, display: options.display } - : this.manifest; - void import('@renderer/features/feature-announcements/present-feature-announcement').then( ({ presentFeatureAnnouncement }) => { - presentFeatureAnnouncement(manifest, () => { + presentFeatureAnnouncement(this.manifest!, () => { if (options?.preview) { this.resetPresentation(); } @@ -102,16 +67,16 @@ export class FeatureAnnouncementStore { ); this.markPresented(); - if (!options?.preview && !(import.meta.env.DEV && this.devRepeatOnLaunch)) { + if (!options?.preview) { markAnnouncementDismissed(this.manifest.id); } } - async replayPreview(display: 'modal' | 'toast'): Promise { + async replayPreview(): Promise { await this.refresh({ preview: true }); if (!this.manifest) return; this.resetPresentation(); - this.present({ preview: true, display }); + this.present({ preview: true }); } clearDismissal(): void { @@ -123,7 +88,7 @@ export class FeatureAnnouncementStore { async start(options?: { isFreshInstall?: boolean }): Promise { await this.refresh(); - if (this.manifest && options?.isFreshInstall && !this.devRepeatOnLaunch) { + if (this.manifest && options?.isFreshInstall) { initializeFreshInstallAnnouncement({ announcementId: this.manifest.id, isFreshInstall: true, diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index 32ec00b2a8..237a3a185b 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -64,7 +64,7 @@ function FeatureAnnouncementToastCard({ return (
- {media && } + {media && } - - -
- } - /> -
+ + + +
+ } + /> ); } ); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index dd44f5415f..72caff6864 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -5,10 +5,6 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; -/** Dev-only: show the announcement on every workspace launch, ignoring dismissal. */ -export const FEATURE_ANNOUNCEMENT_DEV_REPEAT_ON_LAUNCH_KEY = - 'emdash:dev:feature-announcements:repeat-on-launch'; - export const FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS = [ 'home', 'automations', diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts index 2773a0d1cb..5e70ebc900 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -7,7 +7,6 @@ import { const sampleManifest = { enabled: true, id: 'automations-2026-06', - display: 'toast' as const, eyebrow: 'Now available', title: 'Emdash Automations', changelogUrl: 'https://emdash.sh/changelog', diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 83d5988db6..8a57f1b372 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -14,8 +14,6 @@ const featureAnnouncementViewSchema = z.enum(FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEW const featureAnnouncementHeroSchema = z.enum(['automations']); -const featureAnnouncementDisplaySchema = z.enum(['modal', 'toast']); - const featureAnnouncementFeatureSchema = z.object({ icon: featureAnnouncementIconSchema, title: z.string().min(1), @@ -35,7 +33,6 @@ const featureAnnouncementCtaSchema = z export const featureAnnouncementManifestSchema = z.object({ enabled: z.boolean().default(false), id: z.string().min(1), - display: featureAnnouncementDisplaySchema.default('toast'), eyebrow: z.string().min(1).default('Now available'), title: z.string().min(1), hero: featureAnnouncementHeroSchema.optional(), @@ -48,7 +45,6 @@ export const featureAnnouncementManifestSchema = z.object({ }); export type FeatureAnnouncementHero = z.infer; -export type FeatureAnnouncementDisplay = z.infer; export type FeatureAnnouncementIcon = z.infer; export type FeatureAnnouncementFeature = z.infer; From 7fb864803b3b6a9ee2ab5fee9d1ce321ca9f9397 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:10:35 +0200 Subject: [PATCH 07/21] test: validate announcement manifest --- .../feature-announcements.schema.json | 117 ++++++++++++++++++ .../emdash-desktop/feature-announcements.toml | 5 + .../feature-announcements.toml.test.ts | 24 ++++ .../shared/feature-announcements/schema.ts | 5 + apps/emdash-desktop/taplo.toml | 4 + 5 files changed, 155 insertions(+) create mode 100644 apps/emdash-desktop/feature-announcements.schema.json create mode 100644 apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts create mode 100644 apps/emdash-desktop/taplo.toml diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json new file mode 100644 index 0000000000..24c31ab61f --- /dev/null +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -0,0 +1,117 @@ +{ + "$id": "feature-announcements.schema.json", + "title": "Emdash feature announcement manifest", + "description": "In-app feature highlight shown as a toast. Runtime validation uses the Zod schema in src/shared/feature-announcements/schema.ts — keep both in sync.", + "type": "object", + "additionalProperties": false, + "required": ["enabled", "id", "title", "changelogUrl", "features"], + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Set to false to hide the announcement without deleting content." + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Stable identifier used for dismissal persistence." + }, + "eyebrow": { + "type": "string", + "minLength": 1, + "default": "Now available" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "hero": { + "type": "string", + "enum": ["automations"], + "description": "Built-in coded hero graphic shipped with the app." + }, + "image": { + "type": "string", + "format": "uri", + "description": "Remote hero image URL. Takes precedence over hero when both are set." + }, + "changelogUrl": { + "type": "string", + "format": "uri" + }, + "learnMoreUrl": { + "type": "string", + "format": "uri" + }, + "minAppVersion": { + "type": "string", + "minLength": 1, + "description": "Semver minimum; older app versions ignore this manifest." + }, + "features": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "$ref": "#/$defs/feature" + } + }, + "cta": { + "$ref": "#/$defs/cta" + } + }, + "$defs": { + "feature": { + "type": "object", + "additionalProperties": false, + "required": ["icon", "title", "description"], + "properties": { + "icon": { + "type": "string", + "enum": ["calendar-clock", "list-checks", "shield", "check", "sparkles", "message-square"] + }, + "title": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + } + } + }, + "cta": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "properties": { + "label": { + "type": "string", + "minLength": 1 + }, + "view": { + "type": "string", + "enum": ["home", "automations", "library", "skills", "mcp", "project", "task", "settings"] + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "oneOf": [ + { + "required": ["view"], + "not": { + "required": ["url"] + } + }, + { + "required": ["url"], + "not": { + "required": ["view"] + } + } + ] + } + } +} diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml index 86233726ec..19cf25e0db 100644 --- a/apps/emdash-desktop/feature-announcements.toml +++ b/apps/emdash-desktop/feature-announcements.toml @@ -1,9 +1,14 @@ +#:schema ./feature-announcements.schema.json +# # In-app feature highlight manifest. # # Edit this file on GitHub to announce major features without shipping a new app # release. Set `enabled = false` to hide the card while keeping content around # for the next launch. # +# Runtime validation: src/shared/feature-announcements/schema.ts (Zod). +# Editor/CI schema: feature-announcements.schema.json (keep in sync with Zod). +# # The app fetches this file from the main branch at startup. # # Media — pick one (`image` wins if both are set): diff --git a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts new file mode 100644 index 0000000000..49e2c3946d --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'smol-toml'; +import { describe, expect, it } from 'vitest'; +import { featureAnnouncementManifestSchema } from '@shared/feature-announcements/schema'; + +const manifestPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../../feature-announcements.toml' +); + +describe('feature-announcements.toml', () => { + it('matches the Zod manifest schema', async () => { + const content = await readFile(manifestPath, 'utf8'); + const parsed = parse(content); + const result = featureAnnouncementManifestSchema.safeParse(parsed); + + expect( + result.success, + result.success ? undefined : JSON.stringify(result.error.format(), null, 2) + ).toBe(true); + }); +}); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 8a57f1b372..0d3c47028a 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -65,3 +65,8 @@ export function parseFeatureAnnouncementManifestRaw( if (!parsed.success) return null; return parsed.data; } + +/** Validates manifest shape and throws with Zod error details (tests/CI). */ +export function assertFeatureAnnouncementManifest(raw: unknown): FeatureAnnouncementManifest { + return featureAnnouncementManifestSchema.parse(raw); +} diff --git a/apps/emdash-desktop/taplo.toml b/apps/emdash-desktop/taplo.toml new file mode 100644 index 0000000000..a52ff0e3fb --- /dev/null +++ b/apps/emdash-desktop/taplo.toml @@ -0,0 +1,4 @@ +include = ["feature-announcements.toml"] + +[schemas] +"feature-announcements.toml" = "feature-announcements.schema.json" From 515da3f7bac3d53b16e9a8fd162a090b0efa1994 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:20:28 +0200 Subject: [PATCH 08/21] fix(announcements): narrow CTA actions --- .../feature-announcements.schema.json | 6 +++--- apps/emdash-desktop/feature-announcements.toml | 2 +- .../feature-announcement-store.test.ts | 5 ----- .../feature-announcement-store.ts | 13 ------------- .../feature-announcement-toast.tsx | 14 +++++++++++--- .../src/shared/feature-announcements/constants.ts | 14 ++------------ .../shared/feature-announcements/schema.test.ts | 4 ++-- .../src/shared/feature-announcements/schema.ts | 10 +++++----- 8 files changed, 24 insertions(+), 44 deletions(-) diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json index 24c31ab61f..fb3ddd4ec8 100644 --- a/apps/emdash-desktop/feature-announcements.schema.json +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -89,9 +89,9 @@ "type": "string", "minLength": 1 }, - "view": { + "action": { "type": "string", - "enum": ["home", "automations", "library", "skills", "mcp", "project", "task", "settings"] + "enum": ["open-automations"] }, "url": { "type": "string", @@ -100,7 +100,7 @@ }, "oneOf": [ { - "required": ["view"], + "required": ["action"], "not": { "required": ["url"] } diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml index 19cf25e0db..464007ebfc 100644 --- a/apps/emdash-desktop/feature-announcements.toml +++ b/apps/emdash-desktop/feature-announcements.toml @@ -41,4 +41,4 @@ description = "Follow live status and review the results of past runs without le [cta] label = "Open Automations" -view = "automations" +action = "open-automations" diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index bb6c4534ca..e2725b567c 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -54,9 +54,4 @@ describe('FeatureAnnouncementStore', () => { ); }); - it('resolves known CTA views', () => { - const store = new FeatureAnnouncementStore(); - expect(store.resolveCtaView('automations')).toBe('automations'); - expect(store.resolveCtaView('unknown-view')).toBeNull(); - }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index d15647786d..ac00051439 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -5,16 +5,8 @@ import { isAnnouncementDismissed, markAnnouncementDismissed, } from '@renderer/features/feature-announcements/feature-announcement-state'; -import { - FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS, - type FeatureAnnouncementNavigableView, -} from '@shared/feature-announcements/constants'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; -function isNavigableView(value: string): value is FeatureAnnouncementNavigableView { - return (FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS as readonly string[]).includes(value); -} - export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; @@ -127,11 +119,6 @@ export class FeatureAnnouncementStore { } } - resolveCtaView(view: string | undefined): FeatureAnnouncementNavigableView | null { - if (!view || !isNavigableView(view)) return null; - return view; - } - /** @internal test helper */ dismissForTest(id: string): void { markAnnouncementDismissed(id); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index 237a3a185b..7d4205f76b 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -9,12 +9,21 @@ import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { cn } from '@renderer/utils/utils'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; const CUSTOM_TOAST_CLASSNAMES = { toast: '!border-none !bg-transparent !p-0 !shadow-none', }; +function handleCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} + export function showFeatureAnnouncementToast( manifest: FeatureAnnouncementManifest, onDismiss?: () => void @@ -55,9 +64,8 @@ function FeatureAnnouncementToastCard({ return; } - const view = appState.featureAnnouncements.resolveCtaView(manifest.cta?.view); - if (view) { - appState.navigation.navigate(view); + if (manifest.cta?.action) { + handleCtaAction(manifest.cta.action); } dismiss(); }; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index 72caff6864..a893a53dab 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -5,16 +5,6 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; -export const FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS = [ - 'home', - 'automations', - 'library', - 'skills', - 'mcp', - 'project', - 'task', - 'settings', -] as const; +export const FEATURE_ANNOUNCEMENT_CTA_ACTIONS = ['open-automations'] as const; -export type FeatureAnnouncementNavigableView = - (typeof FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS)[number]; +export type FeatureAnnouncementCtaAction = (typeof FEATURE_ANNOUNCEMENT_CTA_ACTIONS)[number]; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts index 5e70ebc900..accb74fc2e 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -21,7 +21,7 @@ const sampleManifest = { ], cta: { label: 'Open Automations', - view: 'automations', + action: 'open-automations', }, }; @@ -45,7 +45,7 @@ describe('feature announcement schema', () => { expect( parseFeatureAnnouncementManifest({ ...sampleManifest, - cta: { label: 'Broken', view: 'automations', url: 'https://example.com' }, + cta: { label: 'Broken', action: 'open-automations', url: 'https://example.com' }, }) ).toBeNull(); }); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 0d3c47028a..13d52b0b2a 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -1,5 +1,5 @@ import z from 'zod'; -import { FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS } from './constants'; +import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } from './constants'; const featureAnnouncementIconSchema = z.enum([ 'calendar-clock', @@ -10,7 +10,7 @@ const featureAnnouncementIconSchema = z.enum([ 'message-square', ]); -const featureAnnouncementViewSchema = z.enum(FEATURE_ANNOUNCEMENT_NAVIGABLE_VIEWS); +const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); const featureAnnouncementHeroSchema = z.enum(['automations']); @@ -23,11 +23,11 @@ const featureAnnouncementFeatureSchema = z.object({ const featureAnnouncementCtaSchema = z .object({ label: z.string().min(1), - view: featureAnnouncementViewSchema.optional(), + action: featureAnnouncementCtaActionSchema.optional(), url: z.url().optional(), }) - .refine((cta) => Boolean(cta.view) !== Boolean(cta.url), { - message: 'CTA must specify exactly one of view or url', + .refine((cta) => Boolean(cta.action) !== Boolean(cta.url), { + message: 'CTA must specify exactly one of action or url', }); export const featureAnnouncementManifestSchema = z.object({ From b3dea475e25bfa87fc7370c8449e5a5083c4c1a6 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:21:34 +0200 Subject: [PATCH 09/21] test(announcements): guard schema drift --- .../feature-announcements.schema.json | 2 +- .../shared/feature-announcements/constants.ts | 11 ++++ .../feature-announcements.toml.test.ts | 34 +++++++++++ .../shared/feature-announcements/schema.ts | 60 ++++++++++--------- 4 files changed, 77 insertions(+), 30 deletions(-) diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json index fb3ddd4ec8..e5058a762c 100644 --- a/apps/emdash-desktop/feature-announcements.schema.json +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -4,7 +4,7 @@ "description": "In-app feature highlight shown as a toast. Runtime validation uses the Zod schema in src/shared/feature-announcements/schema.ts — keep both in sync.", "type": "object", "additionalProperties": false, - "required": ["enabled", "id", "title", "changelogUrl", "features"], + "required": ["id", "title", "changelogUrl", "features"], "properties": { "enabled": { "type": "boolean", diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index a893a53dab..20cc82a371 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -5,6 +5,17 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; +export const FEATURE_ANNOUNCEMENT_ICONS = [ + 'calendar-clock', + 'list-checks', + 'shield', + 'check', + 'sparkles', + 'message-square', +] as const; + +export const FEATURE_ANNOUNCEMENT_HEROES = ['automations'] as const; + export const FEATURE_ANNOUNCEMENT_CTA_ACTIONS = ['open-automations'] as const; export type FeatureAnnouncementCtaAction = (typeof FEATURE_ANNOUNCEMENT_CTA_ACTIONS)[number]; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts index 49e2c3946d..c862ea5262 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -3,12 +3,21 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'smol-toml'; import { describe, expect, it } from 'vitest'; +import { + FEATURE_ANNOUNCEMENT_CTA_ACTIONS, + FEATURE_ANNOUNCEMENT_HEROES, + FEATURE_ANNOUNCEMENT_ICONS, +} from '@shared/feature-announcements/constants'; import { featureAnnouncementManifestSchema } from '@shared/feature-announcements/schema'; const manifestPath = join( dirname(fileURLToPath(import.meta.url)), '../../../feature-announcements.toml' ); +const editorSchemaPath = join( + dirname(fileURLToPath(import.meta.url)), + '../../../feature-announcements.schema.json' +); describe('feature-announcements.toml', () => { it('matches the Zod manifest schema', async () => { @@ -21,4 +30,29 @@ describe('feature-announcements.toml', () => { result.success ? undefined : JSON.stringify(result.error.format(), null, 2) ).toBe(true); }); + + it('keeps the editor JSON schema aligned with the runtime contract', async () => { + const schema = JSON.parse(await readFile(editorSchemaPath, 'utf8')) as { + additionalProperties?: boolean; + required?: string[]; + properties?: Record; + $defs?: { + feature?: { + additionalProperties?: boolean; + properties?: Record; + }; + cta?: { additionalProperties?: boolean; properties?: Record }; + }; + }; + + expect(schema.additionalProperties).toBe(false); + expect(schema.required).toEqual(['id', 'title', 'changelogUrl', 'features']); + expect(schema.properties?.hero?.enum).toEqual([...FEATURE_ANNOUNCEMENT_HEROES]); + expect(schema.$defs?.feature?.additionalProperties).toBe(false); + expect(schema.$defs?.feature?.properties?.icon?.enum).toEqual([...FEATURE_ANNOUNCEMENT_ICONS]); + expect(schema.$defs?.cta?.additionalProperties).toBe(false); + expect(schema.$defs?.cta?.properties?.action?.enum).toEqual([ + ...FEATURE_ANNOUNCEMENT_CTA_ACTIONS, + ]); + }); }); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 13d52b0b2a..f1d664176f 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -1,24 +1,23 @@ import z from 'zod'; -import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } from './constants'; +import { + FEATURE_ANNOUNCEMENT_CTA_ACTIONS, + FEATURE_ANNOUNCEMENT_HEROES, + FEATURE_ANNOUNCEMENT_ICONS, +} from './constants'; -const featureAnnouncementIconSchema = z.enum([ - 'calendar-clock', - 'list-checks', - 'shield', - 'check', - 'sparkles', - 'message-square', -]); +const featureAnnouncementIconSchema = z.enum(FEATURE_ANNOUNCEMENT_ICONS); const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); -const featureAnnouncementHeroSchema = z.enum(['automations']); +const featureAnnouncementHeroSchema = z.enum(FEATURE_ANNOUNCEMENT_HEROES); -const featureAnnouncementFeatureSchema = z.object({ - icon: featureAnnouncementIconSchema, - title: z.string().min(1), - description: z.string().min(1), -}); +const featureAnnouncementFeatureSchema = z + .object({ + icon: featureAnnouncementIconSchema, + title: z.string().min(1), + description: z.string().min(1), + }) + .strict(); const featureAnnouncementCtaSchema = z .object({ @@ -28,21 +27,24 @@ const featureAnnouncementCtaSchema = z }) .refine((cta) => Boolean(cta.action) !== Boolean(cta.url), { message: 'CTA must specify exactly one of action or url', - }); + }) + .strict(); -export const featureAnnouncementManifestSchema = z.object({ - enabled: z.boolean().default(false), - id: z.string().min(1), - eyebrow: z.string().min(1).default('Now available'), - title: z.string().min(1), - hero: featureAnnouncementHeroSchema.optional(), - image: z.url().optional(), - changelogUrl: z.url(), - learnMoreUrl: z.url().optional(), - minAppVersion: z.string().min(1).optional(), - features: z.array(featureAnnouncementFeatureSchema).min(1).max(4), - cta: featureAnnouncementCtaSchema.optional(), -}); +export const featureAnnouncementManifestSchema = z + .object({ + enabled: z.boolean().default(false), + id: z.string().min(1), + eyebrow: z.string().min(1).default('Now available'), + title: z.string().min(1), + hero: featureAnnouncementHeroSchema.optional(), + image: z.url().optional(), + changelogUrl: z.url(), + learnMoreUrl: z.url().optional(), + minAppVersion: z.string().min(1).optional(), + features: z.array(featureAnnouncementFeatureSchema).min(1).max(4), + cta: featureAnnouncementCtaSchema.optional(), + }) + .strict(); export type FeatureAnnouncementHero = z.infer; From afbe1103157e38ab793eb8f98959d01e7e20acdc Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:23:31 +0200 Subject: [PATCH 10/21] refactor(announcements): isolate toast effects --- .../feature-announcement-launcher.tsx | 26 +++++++++++++++--- .../feature-announcement-store.test.ts | 10 ++++++- .../feature-announcement-store.ts | 27 ++++++------------- .../feature-announcement-toast.tsx | 24 +++++++---------- .../present-feature-announcement.ts | 9 ------- 5 files changed, 50 insertions(+), 46 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx index f4442cf149..d20e3ee2f7 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx @@ -1,6 +1,16 @@ import { observer } from 'mobx-react-lite'; import { useEffect } from 'react'; +import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; import { appState } from '@renderer/lib/stores/app-state'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; + +function handleCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncementLauncher({ active, @@ -10,14 +20,24 @@ export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncement const store = appState.featureAnnouncements; useEffect(() => { - if (!active || store.status !== 'ready' || !store.shouldPresent) return; + const manifest = store.manifest; + if (!active || store.status !== 'ready' || !store.shouldPresent || !manifest) return; const timer = setTimeout(() => { - store.present(); + const isPreview = store.isPreview; + store.markPresented(); + showFeatureAnnouncementToast(manifest, { + onAction: handleCtaAction, + onDismiss: () => { + if (isPreview) { + store.resetPresentation(); + } + }, + }); }, 800); return () => clearTimeout(timer); - }, [active, store, store.status, store.shouldPresent]); + }, [active, store, store.status, store.shouldPresent, store.manifest, store.isPreview]); return null; }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index e2725b567c..9a087d4df2 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -47,11 +47,19 @@ describe('FeatureAnnouncementStore', () => { it('persists dismissed announcement ids', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.dismissForTest(manifest.id); + store.markPresented(); expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBe( JSON.stringify(['test-announcement']) ); }); + it('does not persist preview presentation', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.isPreview = true; + store.markPresented(); + + expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBeNull(); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index ac00051439..0f19829ce5 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -10,12 +10,15 @@ import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/ export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; + isPreview = false; private hasPresented = false; constructor() { - makeObservable(this, { + makeObservable(this, { manifest: observable, status: observable, + isPreview: observable, + hasPresented: observable, shouldPresent: computed, markPresented: action, resetPresentation: action, @@ -26,6 +29,7 @@ export class FeatureAnnouncementStore { get shouldPresent(): boolean { if (!this.manifest || this.hasPresented) return false; + if (this.isPreview) return true; return !isAnnouncementDismissed(this.manifest.id); } @@ -42,24 +46,9 @@ export class FeatureAnnouncementStore { } markPresented(): void { - this.hasPresented = true; - } - - present(options?: { preview?: boolean }): void { if (!this.manifest) return; - - void import('@renderer/features/feature-announcements/present-feature-announcement').then( - ({ presentFeatureAnnouncement }) => { - presentFeatureAnnouncement(this.manifest!, () => { - if (options?.preview) { - this.resetPresentation(); - } - }); - } - ); - - this.markPresented(); - if (!options?.preview) { + this.hasPresented = true; + if (!this.isPreview) { markAnnouncementDismissed(this.manifest.id); } } @@ -68,7 +57,6 @@ export class FeatureAnnouncementStore { await this.refresh({ preview: true }); if (!this.manifest) return; this.resetPresentation(); - this.present({ preview: true }); } clearDismissal(): void { @@ -93,6 +81,7 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; + this.isPreview = Boolean(options?.preview); if (!options?.preview) { this.resetPresentation(); } diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index 7d4205f76b..aba47976d3 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -6,7 +6,6 @@ import { getFeatureAnnouncementMedia, } from '@renderer/features/feature-announcements/feature-announcement-media'; import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; -import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { cn } from '@renderer/utils/utils'; import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; @@ -16,20 +15,17 @@ const CUSTOM_TOAST_CLASSNAMES = { toast: '!border-none !bg-transparent !p-0 !shadow-none', }; -function handleCtaAction(action: FeatureAnnouncementCtaAction): void { - switch (action) { - case 'open-automations': - appState.navigation.navigate('automations'); - break; - } -} +type FeatureAnnouncementToastOptions = { + onAction?: (action: FeatureAnnouncementCtaAction) => void; + onDismiss?: () => void; +}; export function showFeatureAnnouncementToast( manifest: FeatureAnnouncementManifest, - onDismiss?: () => void + options?: FeatureAnnouncementToastOptions ): void { toast.custom( - (id) => , + (id) => , { duration: Infinity, classNames: CUSTOM_TOAST_CLASSNAMES, @@ -40,16 +36,16 @@ export function showFeatureAnnouncementToast( function FeatureAnnouncementToastCard({ manifest, toastId, - onDismiss, + options, }: { manifest: FeatureAnnouncementManifest; toastId: string | number; - onDismiss?: () => void; + options?: FeatureAnnouncementToastOptions; }) { const media = getFeatureAnnouncementMedia(manifest); const dismiss = () => { toast.dismiss(toastId); - onDismiss?.(); + options?.onDismiss?.(); }; const handleLearnMore = () => { @@ -65,7 +61,7 @@ function FeatureAnnouncementToastCard({ } if (manifest.cta?.action) { - handleCtaAction(manifest.cta.action); + options?.onAction?.(manifest.cta.action); } dismiss(); }; diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts deleted file mode 100644 index f766b689a7..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -export function presentFeatureAnnouncement( - manifest: FeatureAnnouncementManifest, - onDismiss?: () => void -): void { - showFeatureAnnouncementToast(manifest, onDismiss); -} From 0684b10152e7a761f66b16efe6c9d47dc419a767 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:26:46 +0200 Subject: [PATCH 11/21] fix(announcements): show dev preview directly --- .../feature-announcement-launcher.tsx | 31 +++++++------------ .../present-feature-announcement.ts | 22 +++++++++++++ .../components/AnnouncementDevControls.tsx | 10 ++++-- 3 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx index d20e3ee2f7..f2465790b4 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx @@ -1,16 +1,7 @@ import { observer } from 'mobx-react-lite'; import { useEffect } from 'react'; -import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; +import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; import { appState } from '@renderer/lib/stores/app-state'; -import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; - -function handleCtaAction(action: FeatureAnnouncementCtaAction): void { - switch (action) { - case 'open-automations': - appState.navigation.navigate('automations'); - break; - } -} export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncementLauncher({ active, @@ -21,19 +12,19 @@ export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncement useEffect(() => { const manifest = store.manifest; - if (!active || store.status !== 'ready' || !store.shouldPresent || !manifest) return; + if ( + !active || + store.isPreview || + store.status !== 'ready' || + !store.shouldPresent || + !manifest + ) { + return; + } const timer = setTimeout(() => { - const isPreview = store.isPreview; store.markPresented(); - showFeatureAnnouncementToast(manifest, { - onAction: handleCtaAction, - onDismiss: () => { - if (isPreview) { - store.resetPresentation(); - } - }, - }); + presentFeatureAnnouncement(manifest); }, 800); return () => clearTimeout(timer); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts new file mode 100644 index 0000000000..0f835d9744 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts @@ -0,0 +1,22 @@ +import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; +import { appState } from '@renderer/lib/stores/app-state'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +function handleCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} + +export function presentFeatureAnnouncement( + manifest: FeatureAnnouncementManifest, + options?: { onDismiss?: () => void } +): void { + showFeatureAnnouncementToast(manifest, { + onAction: handleCtaAction, + onDismiss: options?.onDismiss, + }); +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx index 2c41a265c5..a3e6af3eff 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -1,6 +1,7 @@ import { Megaphone, RotateCcw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { SettingRow } from './SettingRow'; @@ -22,8 +23,13 @@ export const AnnouncementDevControls = observer( type="button" size="sm" variant="outline" - onClick={() => { - void store.replayPreview(); + onClick={async () => { + await store.replayPreview(); + if (!store.manifest) return; + store.markPresented(); + presentFeatureAnnouncement(store.manifest, { + onDismiss: () => store.resetPresentation(), + }); }} > From f18ae68d4f67805f0d84f9bcc3219410ff841fe7 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:34:22 +0200 Subject: [PATCH 12/21] fix(announcements): persist dismissals in settings --- .../src/main/core/settings/schema.ts | 7 + .../main/core/settings/settings-registry.ts | 4 + .../feature-announcement-launcher.tsx | 2 +- .../feature-announcement-state.test.ts | 116 +++++++++------- .../feature-announcement-state.ts | 127 ++++++++++++------ .../feature-announcement-store.test.ts | 38 +++--- .../feature-announcement-store.ts | 32 +++-- .../components/AnnouncementDevControls.tsx | 6 +- .../src/shared/core/app-settings.ts | 2 + .../shared/feature-announcements/constants.ts | 2 - 10 files changed, 215 insertions(+), 121 deletions(-) diff --git a/apps/emdash-desktop/src/main/core/settings/schema.ts b/apps/emdash-desktop/src/main/core/settings/schema.ts index 4ee4f16570..f05ba1df93 100644 --- a/apps/emdash-desktop/src/main/core/settings/schema.ts +++ b/apps/emdash-desktop/src/main/core/settings/schema.ts @@ -98,6 +98,11 @@ export const changesViewModeSchema = z.object({ export const browserPreviewSettingsSchema = z.object({ enabled: z.boolean() }); +export const announcementSettingsSchema = z.object({ + initialized: z.boolean(), + dismissedIds: z.array(z.string()), +}); + export const browserProfileIdSchema = z .string() .regex(/^[a-z0-9][a-z0-9-]{0,63}$/) @@ -145,6 +150,7 @@ export const APP_SETTINGS_SCHEMA_MAP = { interface: interfaceSettingsSchema, terminal: terminalSettingsSchema, browserPreview: browserPreviewSettingsSchema, + announcements: announcementSettingsSchema, browser: browserSettingsSchema, resourceMonitor: resourceMonitorSettingsSchema, changesViewMode: changesViewModeSchema, @@ -162,6 +168,7 @@ export const appSettingsSchema = z.object({ interface: interfaceSettingsSchema, terminal: terminalSettingsSchema, browserPreview: browserPreviewSettingsSchema, + announcements: announcementSettingsSchema, browser: browserSettingsSchema, resourceMonitor: resourceMonitorSettingsSchema, changesViewMode: changesViewModeSchema, diff --git a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts index d8606c1435..25b93cddbf 100644 --- a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts +++ b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts @@ -64,6 +64,10 @@ export const SETTINGS_DEFAULTS = { browserPreview: { enabled: true, }, + announcements: { + initialized: false, + dismissedIds: [] as string[], + }, browser: { defaultProfileId: DEFAULT_BROWSER_PROFILE_ID, relaxCorsForLocalhost: false, diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx index f2465790b4..0b04259c47 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx @@ -23,7 +23,7 @@ export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncement } const timer = setTimeout(() => { - store.markPresented(); + void store.markPresented(); presentFeatureAnnouncement(manifest); }, 800); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts index c844a15179..62e3f3de38 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts @@ -2,72 +2,92 @@ import { describe, expect, it } from 'vitest'; import { clearAnnouncementDismissal, initializeFreshInstallAnnouncement, - isAnnouncementDismissed, markAnnouncementDismissed, - readDismissedIds, + readAnnouncementDismissalState, } from '@renderer/features/feature-announcements/feature-announcement-state'; -import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; +import type { AnnouncementSettings } from '@shared/core/app-settings'; -function makeStorage(initial: Record = {}) { - const data = new Map(Object.entries(initial)); +function makeSettingsClient( + initial: AnnouncementSettings = { initialized: false, dismissedIds: [] } +) { + let settings = initial; return { - getItem: (key: string) => data.get(key) ?? null, - setItem: (key: string, value: string) => void data.set(key, value), - dump: () => Object.fromEntries(data), + get: async () => settings, + update: async (next: AnnouncementSettings) => { + settings = next; + }, + read: () => settings, }; } describe('feature announcement state', () => { - it('marks the current announcement as dismissed on a fresh install', () => { - const storage = makeStorage(); - initializeFreshInstallAnnouncement({ - announcementId: 'automations-2026-06', - isFreshInstall: true, - storage, + it('marks the current announcement as dismissed on a fresh install', async () => { + const client = makeSettingsClient(); + await initializeFreshInstallAnnouncement( + { + announcementId: 'automations-2026-06', + isFreshInstall: true, + }, + client + ); + + expect(client.read()).toEqual({ + initialized: true, + dismissedIds: ['automations-2026-06'], }); - expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(true); }); - it('does not touch already-initialized state on a fresh-install flag', () => { - const storage = makeStorage({ - [FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY]: JSON.stringify(['older']), - }); - initializeFreshInstallAnnouncement({ - announcementId: 'automations-2026-06', - isFreshInstall: true, - storage, - }); - expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(false); + it('does not touch already-initialized state on a fresh-install flag', async () => { + const client = makeSettingsClient({ initialized: true, dismissedIds: ['older'] }); + await initializeFreshInstallAnnouncement( + { + announcementId: 'automations-2026-06', + isFreshInstall: true, + }, + client + ); + + expect(client.read()).toEqual({ initialized: true, dismissedIds: ['older'] }); }); - it('shows existing users unseen announcements', () => { - const storage = makeStorage(); - initializeFreshInstallAnnouncement({ - announcementId: 'automations-2026-06', - isFreshInstall: false, - storage, - }); - expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(false); + it('shows existing users unseen announcements', async () => { + const client = makeSettingsClient(); + await initializeFreshInstallAnnouncement( + { + announcementId: 'automations-2026-06', + isFreshInstall: false, + }, + client + ); + + expect(client.read()).toEqual({ initialized: false, dismissedIds: [] }); }); - it('is idempotent when marking the same announcement twice', () => { - const storage = makeStorage(); - markAnnouncementDismissed('newer', storage); - markAnnouncementDismissed('newer', storage); - expect(JSON.parse(storage.dump()[FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY])).toEqual([ - 'newer', - ]); + it('is idempotent when marking the same announcement twice', async () => { + const client = makeSettingsClient(); + await markAnnouncementDismissed('newer', client); + await markAnnouncementDismissed('newer', client); + + expect(client.read()).toEqual({ initialized: true, dismissedIds: ['newer'] }); }); - it('clears a previously dismissed announcement id', () => { - const storage = makeStorage(); - markAnnouncementDismissed('automations-2026-06', storage); - clearAnnouncementDismissal('automations-2026-06', storage); - expect(isAnnouncementDismissed('automations-2026-06', storage)).toBe(false); + it('clears a previously dismissed announcement id', async () => { + const client = makeSettingsClient(); + await markAnnouncementDismissed('automations-2026-06', client); + await clearAnnouncementDismissal('automations-2026-06', client); + + expect(client.read()).toEqual({ initialized: true, dismissedIds: [] }); }); - it('treats corrupted storage as empty dismissed state', () => { - const storage = makeStorage({ [FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY]: 'not json{' }); - expect(readDismissedIds(storage)).toEqual(new Set()); + it('normalizes stored dismissed ids', async () => { + const client = makeSettingsClient({ + initialized: true, + dismissedIds: ['z', '', 'a', 'z'], + }); + + await expect(readAnnouncementDismissalState(client)).resolves.toEqual({ + initialized: true, + dismissedIds: ['a', 'z'], + }); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts index e218574e37..3f60fc89a6 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts @@ -1,59 +1,98 @@ -import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; - -type StorageLike = Pick; - -/** Returns null when announcement tracking has never been initialized. */ -export function readDismissedIds(storage: StorageLike = localStorage): Set | null { - try { - const raw = storage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY); - if (raw === null) return null; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return new Set(); - return new Set(parsed.filter((id): id is string => typeof id === 'string')); - } catch { - return new Set(); - } +import type { AnnouncementSettings } from '@shared/core/app-settings'; + +type AnnouncementSettingsClient = { + get: () => Promise; + update: (settings: AnnouncementSettings) => Promise; +}; + +const appSettingsClient: AnnouncementSettingsClient = { + get: async () => { + const { rpc } = await import('@renderer/lib/ipc'); + return rpc.appSettings.get('announcements') as Promise; + }, + update: async (settings) => { + const { rpc } = await import('@renderer/lib/ipc'); + return rpc.appSettings.update('announcements', settings); + }, +}; + +function normalizeDismissedIds(ids: string[]): string[] { + return [...new Set(ids.filter((id) => id.length > 0))].sort(); +} + +export async function readAnnouncementDismissalState( + client: AnnouncementSettingsClient = appSettingsClient +): Promise { + const settings = await client.get(); + return { + initialized: settings.initialized, + dismissedIds: normalizeDismissedIds(settings.dismissedIds), + }; } -export function writeDismissedIds(ids: Set, storage: StorageLike = localStorage): void { - try { - storage.setItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY, JSON.stringify([...ids].sort())); - } catch { - // localStorage may be unavailable - } +export async function writeAnnouncementDismissalState( + settings: AnnouncementSettings, + client: AnnouncementSettingsClient = appSettingsClient +): Promise { + await client.update({ + initialized: settings.initialized, + dismissedIds: normalizeDismissedIds(settings.dismissedIds), + }); } /** * Fresh installs shouldn't greet new users with a backlog of announcements. * Mark the current manifest as dismissed on first launch without showing it. */ -export function initializeFreshInstallAnnouncement(options: { - announcementId: string; - isFreshInstall: boolean; - storage?: StorageLike; -}): void { - const storage = options.storage ?? localStorage; +export async function initializeFreshInstallAnnouncement( + options: { + announcementId: string; + isFreshInstall: boolean; + }, + client: AnnouncementSettingsClient = appSettingsClient +): Promise { if (!options.isFreshInstall) return; - if (readDismissedIds(storage) !== null) return; - writeDismissedIds(new Set([options.announcementId]), storage); -} -export function markAnnouncementDismissed(id: string, storage: StorageLike = localStorage): void { - const dismissed = readDismissedIds(storage) ?? new Set(); - if (dismissed.has(id)) return; - writeDismissedIds(new Set([...dismissed, id]), storage); + const settings = await readAnnouncementDismissalState(client); + if (settings.initialized) return; + + await writeAnnouncementDismissalState( + { + initialized: true, + dismissedIds: [options.announcementId], + }, + client + ); } -export function isAnnouncementDismissed(id: string, storage: StorageLike = localStorage): boolean { - const dismissed = readDismissedIds(storage); - if (dismissed === null) return false; - return dismissed.has(id); +export async function markAnnouncementDismissed( + id: string, + client: AnnouncementSettingsClient = appSettingsClient +): Promise { + const settings = await readAnnouncementDismissalState(client); + if (settings.initialized && settings.dismissedIds.includes(id)) return; + + await writeAnnouncementDismissalState( + { + initialized: true, + dismissedIds: [...settings.dismissedIds, id], + }, + client + ); } -export function clearAnnouncementDismissal(id: string, storage: StorageLike = localStorage): void { - const dismissed = readDismissedIds(storage); - if (dismissed === null || !dismissed.has(id)) return; - const next = new Set(dismissed); - next.delete(id); - writeDismissedIds(next, storage); +export async function clearAnnouncementDismissal( + id: string, + client: AnnouncementSettingsClient = appSettingsClient +): Promise { + const settings = await readAnnouncementDismissalState(client); + if (!settings.dismissedIds.includes(id)) return; + + await writeAnnouncementDismissalState( + { + initialized: true, + dismissedIds: settings.dismissedIds.filter((dismissedId) => dismissedId !== id), + }, + client + ); } diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index 9a087d4df2..5ac257f623 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -1,8 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FeatureAnnouncementStore } from '@renderer/features/feature-announcements/feature-announcement-store'; -import { FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@shared/feature-announcements/constants'; +import { rpc } from '@renderer/lib/ipc'; +import type { AnnouncementSettings } from '@shared/core/app-settings'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; +vi.mock('@renderer/lib/ipc', () => ({ + rpc: { + appSettings: { + get: vi.fn(), + update: vi.fn(), + }, + }, +})); + const manifest: FeatureAnnouncementManifest = { enabled: true, id: 'test-announcement', @@ -19,13 +29,13 @@ const manifest: FeatureAnnouncementManifest = { }; describe('FeatureAnnouncementStore', () => { + let settings: AnnouncementSettings; + beforeEach(() => { - const storage = new Map(); - vi.stubGlobal('localStorage', { - clear: vi.fn(() => storage.clear()), - getItem: vi.fn((key: string) => storage.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => storage.set(key, value)), - removeItem: vi.fn((key: string) => storage.delete(key)), + settings = { initialized: false, dismissedIds: [] }; + vi.mocked(rpc.appSettings.get).mockImplementation(async () => settings); + vi.mocked(rpc.appSettings.update).mockImplementation(async (_key, next) => { + settings = next as AnnouncementSettings; }); }); @@ -44,22 +54,20 @@ describe('FeatureAnnouncementStore', () => { expect(store.shouldPresent).toBe(true); }); - it('persists dismissed announcement ids', () => { + it('persists dismissed announcement ids', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.markPresented(); + await store.markPresented(); - expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBe( - JSON.stringify(['test-announcement']) - ); + expect(settings).toEqual({ initialized: true, dismissedIds: ['test-announcement'] }); }); - it('does not persist preview presentation', () => { + it('does not persist preview presentation', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); store.isPreview = true; - store.markPresented(); + await store.markPresented(); - expect(localStorage.getItem(FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)).toBeNull(); + expect(settings).toEqual({ initialized: false, dismissedIds: [] }); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index 0f19829ce5..5797f6d0d4 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -2,8 +2,8 @@ import { action, computed, makeObservable, observable, runInAction } from 'mobx' import { clearAnnouncementDismissal, initializeFreshInstallAnnouncement, - isAnnouncementDismissed, markAnnouncementDismissed, + readAnnouncementDismissalState, } from '@renderer/features/feature-announcements/feature-announcement-state'; import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; @@ -11,6 +11,7 @@ export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; isPreview = false; + dismissedIds = new Set(); private hasPresented = false; constructor() { @@ -18,6 +19,7 @@ export class FeatureAnnouncementStore { manifest: observable, status: observable, isPreview: observable, + dismissedIds: observable, hasPresented: observable, shouldPresent: computed, markPresented: action, @@ -30,7 +32,7 @@ export class FeatureAnnouncementStore { get shouldPresent(): boolean { if (!this.manifest || this.hasPresented) return false; if (this.isPreview) return true; - return !isAnnouncementDismissed(this.manifest.id); + return !this.dismissedIds.has(this.manifest.id); } setManifest(manifest: FeatureAnnouncementManifest | null): void { @@ -45,11 +47,13 @@ export class FeatureAnnouncementStore { this.hasPresented = false; } - markPresented(): void { + async markPresented(): Promise { if (!this.manifest) return; this.hasPresented = true; if (!this.isPreview) { - markAnnouncementDismissed(this.manifest.id); + const announcementId = this.manifest.id; + this.dismissedIds = new Set([...this.dismissedIds, announcementId]); + await markAnnouncementDismissed(announcementId); } } @@ -59,23 +63,33 @@ export class FeatureAnnouncementStore { this.resetPresentation(); } - clearDismissal(): void { + async clearDismissal(): Promise { if (!this.manifest) return; - clearAnnouncementDismissal(this.manifest.id); + const announcementId = this.manifest.id; + this.dismissedIds = new Set([...this.dismissedIds].filter((id) => id !== announcementId)); + await clearAnnouncementDismissal(announcementId); this.resetPresentation(); } async start(options?: { isFreshInstall?: boolean }): Promise { - await this.refresh(); + await Promise.all([this.refresh(), this.loadDismissalState()]); if (this.manifest && options?.isFreshInstall) { - initializeFreshInstallAnnouncement({ + await initializeFreshInstallAnnouncement({ announcementId: this.manifest.id, isFreshInstall: true, }); + await this.loadDismissalState(); } } + async loadDismissalState(): Promise { + const settings = await readAnnouncementDismissalState(); + runInAction(() => { + this.dismissedIds = new Set(settings.dismissedIds); + }); + } + async refresh(options?: { preview?: boolean }): Promise { const { rpc } = await import('@renderer/lib/ipc'); @@ -110,6 +124,6 @@ export class FeatureAnnouncementStore { /** @internal test helper */ dismissForTest(id: string): void { - markAnnouncementDismissed(id); + this.dismissedIds = new Set([...this.dismissedIds, id]); } } diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx index a3e6af3eff..157b44fb7b 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -26,7 +26,7 @@ export const AnnouncementDevControls = observer( onClick={async () => { await store.replayPreview(); if (!store.manifest) return; - store.markPresented(); + await store.markPresented(); presentFeatureAnnouncement(store.manifest, { onDismiss: () => store.resetPresentation(), }); @@ -39,7 +39,9 @@ export const AnnouncementDevControls = observer( type="button" size="sm" variant="outline" - onClick={() => store.clearDismissal()} + onClick={() => { + void store.clearDismissal(); + }} disabled={!store.manifest} > diff --git a/apps/emdash-desktop/src/shared/core/app-settings.ts b/apps/emdash-desktop/src/shared/core/app-settings.ts index bb2fdee1d3..e6efeeff54 100644 --- a/apps/emdash-desktop/src/shared/core/app-settings.ts +++ b/apps/emdash-desktop/src/shared/core/app-settings.ts @@ -3,6 +3,7 @@ import { appSettingsSchema, type browserSettingsSchema, type changesViewModeSchema, + type announcementSettingsSchema, type interfaceSettingsSchema, type localProjectSettingsSchema, type notificationSettingsSchema, @@ -21,6 +22,7 @@ export type TerminalSettings = z.infer; export type Theme = z.infer; export type InterfaceSettings = z.infer; +export type AnnouncementSettings = z.infer; export type ProviderCustomConfig = z.infer; export type ProviderCustomConfigs = Record; export type ChangesViewMode = z.infer; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index 20cc82a371..d703494eb2 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -3,8 +3,6 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME = 'feature-announcements.tom export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = 'https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/feature-announcements.toml'; -export const FEATURE_ANNOUNCEMENT_DISMISSED_STORAGE_KEY = 'emdash:feature-announcements:dismissed'; - export const FEATURE_ANNOUNCEMENT_ICONS = [ 'calendar-clock', 'list-checks', From d9fdd0039afa224438589c6584d057e1f1b5a8c4 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:50:45 +0200 Subject: [PATCH 13/21] fix(announcements): address review feedback --- .../feature-announcements.schema.json | 2 +- .../feature-announcement-media.tsx | 6 ----- .../feature-announcement-state.test.ts | 15 +++++++++++++ .../feature-announcement-state.ts | 4 ++-- .../feature-announcement-store.test.ts | 22 +++++++++++++++++++ .../feature-announcement-store.ts | 4 ++-- .../feature-announcement-toast.tsx | 8 +++---- 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json index e5058a762c..f25cba20b5 100644 --- a/apps/emdash-desktop/feature-announcements.schema.json +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -108,7 +108,7 @@ { "required": ["url"], "not": { - "required": ["view"] + "required": ["action"] } } ] diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx index d18837c4f7..31b9116a17 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx @@ -76,9 +76,3 @@ export function FeatureAnnouncementMediaArea({ media }: { media: FeatureAnnounce
); } - -export function getFeatureAnnouncementMedia( - manifest: FeatureAnnouncementManifest -): FeatureAnnouncementMedia | null { - return resolveFeatureAnnouncementMedia(manifest); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts index 62e3f3de38..63936e1aa0 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts @@ -37,6 +37,21 @@ describe('feature announcement state', () => { }); }); + it('initializes fresh installs even when no announcement is available', async () => { + const client = makeSettingsClient(); + await initializeFreshInstallAnnouncement( + { + isFreshInstall: true, + }, + client + ); + + expect(client.read()).toEqual({ + initialized: true, + dismissedIds: [], + }); + }); + it('does not touch already-initialized state on a fresh-install flag', async () => { const client = makeSettingsClient({ initialized: true, dismissedIds: ['older'] }); await initializeFreshInstallAnnouncement( diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts index 3f60fc89a6..3c34fd4100 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts @@ -46,7 +46,7 @@ export async function writeAnnouncementDismissalState( */ export async function initializeFreshInstallAnnouncement( options: { - announcementId: string; + announcementId?: string; isFreshInstall: boolean; }, client: AnnouncementSettingsClient = appSettingsClient @@ -59,7 +59,7 @@ export async function initializeFreshInstallAnnouncement( await writeAnnouncementDismissalState( { initialized: true, - dismissedIds: [options.announcementId], + dismissedIds: options.announcementId ? [options.announcementId] : [], }, client ); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index 5ac257f623..07182b3a6e 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -10,6 +10,10 @@ vi.mock('@renderer/lib/ipc', () => ({ get: vi.fn(), update: vi.fn(), }, + featureAnnouncements: { + getCurrent: vi.fn(), + preview: vi.fn(), + }, }, })); @@ -37,6 +41,14 @@ describe('FeatureAnnouncementStore', () => { vi.mocked(rpc.appSettings.update).mockImplementation(async (_key, next) => { settings = next as AnnouncementSettings; }); + vi.mocked(rpc.featureAnnouncements.getCurrent).mockResolvedValue({ + success: true, + data: null, + }); + vi.mocked(rpc.featureAnnouncements.preview).mockResolvedValue({ + success: true, + data: null, + }); }); it('does not present dismissed announcements', () => { @@ -70,4 +82,14 @@ describe('FeatureAnnouncementStore', () => { expect(settings).toEqual({ initialized: false, dismissedIds: [] }); }); + + it('initializes fresh-install dismissal state when manifest fetch fails', async () => { + vi.mocked(rpc.featureAnnouncements.getCurrent).mockRejectedValue(new Error('offline')); + + const store = new FeatureAnnouncementStore(); + await store.start({ isFreshInstall: true }); + + expect(store.status).toBe('error'); + expect(settings).toEqual({ initialized: true, dismissedIds: [] }); + }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index 5797f6d0d4..ba6b5b3d26 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -74,9 +74,9 @@ export class FeatureAnnouncementStore { async start(options?: { isFreshInstall?: boolean }): Promise { await Promise.all([this.refresh(), this.loadDismissalState()]); - if (this.manifest && options?.isFreshInstall) { + if (options?.isFreshInstall) { await initializeFreshInstallAnnouncement({ - announcementId: this.manifest.id, + announcementId: this.manifest?.id, isFreshInstall: true, }); await this.loadDismissalState(); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx index aba47976d3..bf407de523 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner'; import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; import { FeatureAnnouncementMediaArea, - getFeatureAnnouncementMedia, + resolveFeatureAnnouncementMedia, } from '@renderer/features/feature-announcements/feature-announcement-media'; import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; import { Button } from '@renderer/lib/ui/button'; @@ -42,7 +42,7 @@ function FeatureAnnouncementToastCard({ toastId: string | number; options?: FeatureAnnouncementToastOptions; }) { - const media = getFeatureAnnouncementMedia(manifest); + const media = resolveFeatureAnnouncementMedia(manifest); const dismiss = () => { toast.dismiss(toastId); options?.onDismiss?.(); @@ -88,10 +88,10 @@ function FeatureAnnouncementToastCard({

{manifest.title}

    - {manifest.features.map((feature) => { + {manifest.features.map((feature, index) => { const Icon = getFeatureAnnouncementIcon(feature.icon); return ( -
  • +
  • {feature.title}

    From 9f24adb9faea41eddb79fead935d5042d2dbcb31 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:00:25 +0200 Subject: [PATCH 14/21] feat(announcements): move announcement to sidebar --- apps/emdash-desktop/src/renderer/App.tsx | 4 - .../feature-announcement-actions.ts | 10 ++ .../feature-announcement-launcher.tsx | 34 ----- .../feature-announcement-sidebar-card.tsx | 77 +++++++++++ .../feature-announcement-store.test.ts | 19 ++- .../feature-announcement-store.ts | 42 +++--- .../feature-announcement-toast.tsx | 122 ------------------ .../present-feature-announcement.ts | 22 ---- .../components/AnnouncementDevControls.tsx | 12 +- .../features/sidebar/left-sidebar.tsx | 2 + 10 files changed, 121 insertions(+), 223 deletions(-) create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-actions.ts delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-card.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index fcacbff576..551a265f97 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; -import { FeatureAnnouncementLauncher } from './features/feature-announcements/feature-announcement-launcher'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -92,8 +91,6 @@ function AppContent() { return ; }; - const workspaceVisible = !isLoading && view === 'workspace'; - return ( @@ -102,7 +99,6 @@ function AppContent() { - diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-actions.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-actions.ts new file mode 100644 index 0000000000..433569842a --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-actions.ts @@ -0,0 +1,10 @@ +import { appState } from '@renderer/lib/stores/app-state'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; + +export function handleFeatureAnnouncementCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx deleted file mode 100644 index 0b04259c47..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; -import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; -import { appState } from '@renderer/lib/stores/app-state'; - -export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncementLauncher({ - active, -}: { - active: boolean; -}) { - const store = appState.featureAnnouncements; - - useEffect(() => { - const manifest = store.manifest; - if ( - !active || - store.isPreview || - store.status !== 'ready' || - !store.shouldPresent || - !manifest - ) { - return; - } - - const timer = setTimeout(() => { - void store.markPresented(); - presentFeatureAnnouncement(manifest); - }, 800); - - return () => clearTimeout(timer); - }, [active, store, store.status, store.shouldPresent, store.manifest, store.isPreview]); - - return null; -}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-card.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-card.tsx new file mode 100644 index 0000000000..50075e232b --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-card.tsx @@ -0,0 +1,77 @@ +import { ChevronRight, XIcon } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { handleFeatureAnnouncementCtaAction } from '@renderer/features/feature-announcements/feature-announcement-actions'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { appState } from '@renderer/lib/stores/app-state'; +import { cn } from '@renderer/utils/utils'; + +export const FeatureAnnouncementSidebarCard = observer(function FeatureAnnouncementSidebarCard() { + const store = appState.featureAnnouncements; + + if (!store.shouldShowInSidebar || !store.manifest) { + return null; + } + + const manifest = store.manifest; + + const handleDismiss = () => { + void store.dismiss(); + }; + + const handleOpen = () => { + if (manifest.cta?.action) { + handleFeatureAnnouncementCtaAction(manifest.cta.action); + void store.dismiss(); + return; + } + + if (manifest.cta?.url) { + confirmOpenExternalLink(manifest.cta.url); + void store.dismiss(); + return; + } + + const url = manifest.learnMoreUrl ?? manifest.changelogUrl; + confirmOpenExternalLink(url); + }; + + const handleChangelog = () => { + confirmOpenExternalLink(manifest.changelogUrl); + }; + + return ( +
    +
    + + +
    +
    + +
    +
    + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index 07182b3a6e..c5995b0739 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -51,35 +51,40 @@ describe('FeatureAnnouncementStore', () => { }); }); - it('does not present dismissed announcements', () => { + it('does not show dismissed announcements in the sidebar', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); + store.setStatus('ready'); store.dismissForTest(manifest.id); - expect(store.shouldPresent).toBe(false); + expect(store.shouldShowInSidebar).toBe(false); }); - it('presents unseen announcements', () => { + it('shows unseen announcements in the sidebar', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); + store.setStatus('ready'); - expect(store.shouldPresent).toBe(true); + expect(store.shouldShowInSidebar).toBe(true); }); it('persists dismissed announcement ids', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - await store.markPresented(); + store.setStatus('ready'); + await store.dismiss(); expect(settings).toEqual({ initialized: true, dismissedIds: ['test-announcement'] }); }); - it('does not persist preview presentation', async () => { + it('does not persist preview dismissal', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); + store.setStatus('ready'); store.isPreview = true; - await store.markPresented(); + await store.dismiss(); + expect(store.shouldShowInSidebar).toBe(false); expect(settings).toEqual({ initialized: false, dismissedIds: [] }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index ba6b5b3d26..ec5d3b09fd 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -11,27 +11,26 @@ export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; isPreview = false; + previewVisible = true; dismissedIds = new Set(); - private hasPresented = false; constructor() { - makeObservable(this, { + makeObservable(this, { manifest: observable, status: observable, isPreview: observable, + previewVisible: observable, dismissedIds: observable, - hasPresented: observable, - shouldPresent: computed, - markPresented: action, - resetPresentation: action, + shouldShowInSidebar: computed, + dismiss: action, setManifest: action, setStatus: action, }); } - get shouldPresent(): boolean { - if (!this.manifest || this.hasPresented) return false; - if (this.isPreview) return true; + get shouldShowInSidebar(): boolean { + if (!this.manifest || this.status !== 'ready') return false; + if (this.isPreview) return this.previewVisible; return !this.dismissedIds.has(this.manifest.id); } @@ -43,24 +42,21 @@ export class FeatureAnnouncementStore { this.status = status; } - resetPresentation(): void { - this.hasPresented = false; - } - - async markPresented(): Promise { + async dismiss(): Promise { if (!this.manifest) return; - this.hasPresented = true; - if (!this.isPreview) { - const announcementId = this.manifest.id; - this.dismissedIds = new Set([...this.dismissedIds, announcementId]); - await markAnnouncementDismissed(announcementId); + if (this.isPreview) { + this.previewVisible = false; + return; } + const announcementId = this.manifest.id; + if (this.dismissedIds.has(announcementId)) return; + this.dismissedIds = new Set([...this.dismissedIds, announcementId]); + await markAnnouncementDismissed(announcementId); } async replayPreview(): Promise { + this.previewVisible = true; await this.refresh({ preview: true }); - if (!this.manifest) return; - this.resetPresentation(); } async clearDismissal(): Promise { @@ -68,7 +64,6 @@ export class FeatureAnnouncementStore { const announcementId = this.manifest.id; this.dismissedIds = new Set([...this.dismissedIds].filter((id) => id !== announcementId)); await clearAnnouncementDismissal(announcementId); - this.resetPresentation(); } async start(options?: { isFreshInstall?: boolean }): Promise { @@ -96,9 +91,6 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; this.isPreview = Boolean(options?.preview); - if (!options?.preview) { - this.resetPresentation(); - } }); try { diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx deleted file mode 100644 index bf407de523..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { ArrowUpRight, XIcon } from 'lucide-react'; -import { toast } from 'sonner'; -import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; -import { - FeatureAnnouncementMediaArea, - resolveFeatureAnnouncementMedia, -} from '@renderer/features/feature-announcements/feature-announcement-media'; -import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; -import { Button } from '@renderer/lib/ui/button'; -import { cn } from '@renderer/utils/utils'; -import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -const CUSTOM_TOAST_CLASSNAMES = { - toast: '!border-none !bg-transparent !p-0 !shadow-none', -}; - -type FeatureAnnouncementToastOptions = { - onAction?: (action: FeatureAnnouncementCtaAction) => void; - onDismiss?: () => void; -}; - -export function showFeatureAnnouncementToast( - manifest: FeatureAnnouncementManifest, - options?: FeatureAnnouncementToastOptions -): void { - toast.custom( - (id) => , - { - duration: Infinity, - classNames: CUSTOM_TOAST_CLASSNAMES, - } - ); -} - -function FeatureAnnouncementToastCard({ - manifest, - toastId, - options, -}: { - manifest: FeatureAnnouncementManifest; - toastId: string | number; - options?: FeatureAnnouncementToastOptions; -}) { - const media = resolveFeatureAnnouncementMedia(manifest); - const dismiss = () => { - toast.dismiss(toastId); - options?.onDismiss?.(); - }; - - const handleLearnMore = () => { - const url = manifest.learnMoreUrl ?? manifest.changelogUrl; - confirmOpenExternalLink(url); - }; - - const handleCta = () => { - if (manifest.cta?.url) { - confirmOpenExternalLink(manifest.cta.url); - dismiss(); - return; - } - - if (manifest.cta?.action) { - options?.onAction?.(manifest.cta.action); - } - dismiss(); - }; - - return ( -
    - {media && } - -
    -
    -

    {manifest.eyebrow}

    -

    {manifest.title}

    -
    -
      - {manifest.features.map((feature, index) => { - const Icon = getFeatureAnnouncementIcon(feature.icon); - return ( -
    • - -
      -

      {feature.title}

      -

      {feature.description}

      -
      -
    • - ); - })} -
    -
    -
    - - {manifest.cta ? ( - - ) : ( - - )} -
    -
    - ); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts deleted file mode 100644 index 0f835d9744..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; -import { appState } from '@renderer/lib/stores/app-state'; -import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -function handleCtaAction(action: FeatureAnnouncementCtaAction): void { - switch (action) { - case 'open-automations': - appState.navigation.navigate('automations'); - break; - } -} - -export function presentFeatureAnnouncement( - manifest: FeatureAnnouncementManifest, - options?: { onDismiss?: () => void } -): void { - showFeatureAnnouncementToast(manifest, { - onAction: handleCtaAction, - onDismiss: options?.onDismiss, - }); -} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx index 157b44fb7b..56ff878019 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -1,7 +1,6 @@ import { Megaphone, RotateCcw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; -import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { SettingRow } from './SettingRow'; @@ -15,7 +14,7 @@ export const AnnouncementDevControls = observer( return ( @@ -23,13 +22,8 @@ export const AnnouncementDevControls = observer( type="button" size="sm" variant="outline" - onClick={async () => { - await store.replayPreview(); - if (!store.manifest) return; - await store.markPresented(); - presentFeatureAnnouncement(store.manifest, { - onDismiss: () => store.resetPresentation(), - }); + onClick={() => { + void store.replayPreview(); }} > diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx index d9c51e5a74..221979194e 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx @@ -1,6 +1,7 @@ import { Clock, FolderInput, Library, MessageSquareShare, Settings } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import { FeatureAnnouncementSidebarCard } from '@renderer/features/feature-announcements/feature-announcement-sidebar-card'; import { isCurrentView, useNavigate, @@ -107,6 +108,7 @@ export const LeftSidebar: React.FC = observer(function LeftSidebar() { +
    - -
    -
    - -
    -
    - ); -}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index c5995b0739..07182b3a6e 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -51,40 +51,35 @@ describe('FeatureAnnouncementStore', () => { }); }); - it('does not show dismissed announcements in the sidebar', () => { + it('does not present dismissed announcements', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.setStatus('ready'); store.dismissForTest(manifest.id); - expect(store.shouldShowInSidebar).toBe(false); + expect(store.shouldPresent).toBe(false); }); - it('shows unseen announcements in the sidebar', () => { + it('presents unseen announcements', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.setStatus('ready'); - expect(store.shouldShowInSidebar).toBe(true); + expect(store.shouldPresent).toBe(true); }); it('persists dismissed announcement ids', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.setStatus('ready'); - await store.dismiss(); + await store.markPresented(); expect(settings).toEqual({ initialized: true, dismissedIds: ['test-announcement'] }); }); - it('does not persist preview dismissal', async () => { + it('does not persist preview presentation', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - store.setStatus('ready'); store.isPreview = true; - await store.dismiss(); + await store.markPresented(); - expect(store.shouldShowInSidebar).toBe(false); expect(settings).toEqual({ initialized: false, dismissedIds: [] }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index ec5d3b09fd..ba6b5b3d26 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -11,26 +11,27 @@ export class FeatureAnnouncementStore { manifest: FeatureAnnouncementManifest | null = null; status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; isPreview = false; - previewVisible = true; dismissedIds = new Set(); + private hasPresented = false; constructor() { - makeObservable(this, { + makeObservable(this, { manifest: observable, status: observable, isPreview: observable, - previewVisible: observable, dismissedIds: observable, - shouldShowInSidebar: computed, - dismiss: action, + hasPresented: observable, + shouldPresent: computed, + markPresented: action, + resetPresentation: action, setManifest: action, setStatus: action, }); } - get shouldShowInSidebar(): boolean { - if (!this.manifest || this.status !== 'ready') return false; - if (this.isPreview) return this.previewVisible; + get shouldPresent(): boolean { + if (!this.manifest || this.hasPresented) return false; + if (this.isPreview) return true; return !this.dismissedIds.has(this.manifest.id); } @@ -42,21 +43,24 @@ export class FeatureAnnouncementStore { this.status = status; } - async dismiss(): Promise { + resetPresentation(): void { + this.hasPresented = false; + } + + async markPresented(): Promise { if (!this.manifest) return; - if (this.isPreview) { - this.previewVisible = false; - return; + this.hasPresented = true; + if (!this.isPreview) { + const announcementId = this.manifest.id; + this.dismissedIds = new Set([...this.dismissedIds, announcementId]); + await markAnnouncementDismissed(announcementId); } - const announcementId = this.manifest.id; - if (this.dismissedIds.has(announcementId)) return; - this.dismissedIds = new Set([...this.dismissedIds, announcementId]); - await markAnnouncementDismissed(announcementId); } async replayPreview(): Promise { - this.previewVisible = true; await this.refresh({ preview: true }); + if (!this.manifest) return; + this.resetPresentation(); } async clearDismissal(): Promise { @@ -64,6 +68,7 @@ export class FeatureAnnouncementStore { const announcementId = this.manifest.id; this.dismissedIds = new Set([...this.dismissedIds].filter((id) => id !== announcementId)); await clearAnnouncementDismissal(announcementId); + this.resetPresentation(); } async start(options?: { isFreshInstall?: boolean }): Promise { @@ -91,6 +96,9 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; this.isPreview = Boolean(options?.preview); + if (!options?.preview) { + this.resetPresentation(); + } }); try { diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx new file mode 100644 index 0000000000..61f7620d2b --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx @@ -0,0 +1,124 @@ +import { ArrowUpRight, XIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { getFeatureAnnouncementIcon } from '@renderer/features/feature-announcements/feature-announcement-icon'; +import { + FeatureAnnouncementMediaArea, + resolveFeatureAnnouncementMedia, +} from '@renderer/features/feature-announcements/feature-announcement-media'; +import { FEATURE_ANNOUNCEMENT_TOASTER_ID } from '@renderer/features/feature-announcements/feature-announcement-toaster'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { Button } from '@renderer/lib/ui/button'; +import { cn } from '@renderer/utils/utils'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +const CUSTOM_TOAST_CLASSNAMES = { + toast: '!border-none !bg-transparent !p-0 !shadow-none', +}; + +type FeatureAnnouncementToastOptions = { + onAction?: (action: FeatureAnnouncementCtaAction) => void; + onDismiss?: () => void; +}; + +export function showFeatureAnnouncementToast( + manifest: FeatureAnnouncementManifest, + options?: FeatureAnnouncementToastOptions +): void { + toast.custom( + (id) => , + { + duration: Infinity, + classNames: CUSTOM_TOAST_CLASSNAMES, + toasterId: FEATURE_ANNOUNCEMENT_TOASTER_ID, + } + ); +} + +function FeatureAnnouncementToastCard({ + manifest, + toastId, + options, +}: { + manifest: FeatureAnnouncementManifest; + toastId: string | number; + options?: FeatureAnnouncementToastOptions; +}) { + const media = resolveFeatureAnnouncementMedia(manifest); + const dismiss = () => { + toast.dismiss(toastId); + options?.onDismiss?.(); + }; + + const handleLearnMore = () => { + const url = manifest.learnMoreUrl ?? manifest.changelogUrl; + confirmOpenExternalLink(url); + }; + + const handleCta = () => { + if (manifest.cta?.url) { + confirmOpenExternalLink(manifest.cta.url); + dismiss(); + return; + } + + if (manifest.cta?.action) { + options?.onAction?.(manifest.cta.action); + } + dismiss(); + }; + + return ( +
    + {media && } + +
    +
    +

    {manifest.eyebrow}

    +

    {manifest.title}

    +
    +
      + {manifest.features.map((feature, index) => { + const Icon = getFeatureAnnouncementIcon(feature.icon); + return ( +
    • + +
      +

      {feature.title}

      +

      {feature.description}

      +
      +
    • + ); + })} +
    +
    +
    + + {manifest.cta ? ( + + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx new file mode 100644 index 0000000000..2824594b71 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx @@ -0,0 +1,23 @@ +import { Toaster as SonnerToaster } from 'sonner'; +import { useTheme } from '@renderer/lib/hooks/useTheme'; + +export const FEATURE_ANNOUNCEMENT_TOASTER_ID = 'feature-announcements'; + +export function FeatureAnnouncementToaster() { + const { effectiveTheme } = useTheme(); + const theme = effectiveTheme === 'emlight' ? 'light' : 'dark'; + + return ( + + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts new file mode 100644 index 0000000000..0f835d9744 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts @@ -0,0 +1,22 @@ +import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; +import { appState } from '@renderer/lib/stores/app-state'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +function handleCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} + +export function presentFeatureAnnouncement( + manifest: FeatureAnnouncementManifest, + options?: { onDismiss?: () => void } +): void { + showFeatureAnnouncementToast(manifest, { + onAction: handleCtaAction, + onDismiss: options?.onDismiss, + }); +} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx index 56ff878019..157b44fb7b 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -1,6 +1,7 @@ import { Megaphone, RotateCcw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { SettingRow } from './SettingRow'; @@ -14,7 +15,7 @@ export const AnnouncementDevControls = observer( return ( @@ -22,8 +23,13 @@ export const AnnouncementDevControls = observer( type="button" size="sm" variant="outline" - onClick={() => { - void store.replayPreview(); + onClick={async () => { + await store.replayPreview(); + if (!store.manifest) return; + await store.markPresented(); + presentFeatureAnnouncement(store.manifest, { + onDismiss: () => store.resetPresentation(), + }); }} > diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx index 221979194e..d9c51e5a74 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx @@ -1,7 +1,6 @@ import { Clock, FolderInput, Library, MessageSquareShare, Settings } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; -import { FeatureAnnouncementSidebarCard } from '@renderer/features/feature-announcements/feature-announcement-sidebar-card'; import { isCurrentView, useNavigate, @@ -108,7 +107,6 @@ export const LeftSidebar: React.FC = observer(function LeftSidebar() { -
      - {manifest.features.map((feature, index) => { - const Icon = getFeatureAnnouncementIcon(feature.icon); - return ( -
    • - -
      -

      {feature.title}

      -

      {feature.description}

      -
      -
    • - ); - })} + {manifest.features.map((feature, index) => ( +
    • +

      {feature.title}

      +

      {feature.description}

      +
    • + ))}
diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index d703494eb2..d043a5321f 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -3,15 +3,6 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME = 'feature-announcements.tom export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = 'https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/feature-announcements.toml'; -export const FEATURE_ANNOUNCEMENT_ICONS = [ - 'calendar-clock', - 'list-checks', - 'shield', - 'check', - 'sparkles', - 'message-square', -] as const; - export const FEATURE_ANNOUNCEMENT_HEROES = ['automations'] as const; export const FEATURE_ANNOUNCEMENT_CTA_ACTIONS = ['open-automations'] as const; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts index c862ea5262..829673ce60 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it } from 'vitest'; import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS, FEATURE_ANNOUNCEMENT_HEROES, - FEATURE_ANNOUNCEMENT_ICONS, } from '@shared/feature-announcements/constants'; import { featureAnnouncementManifestSchema } from '@shared/feature-announcements/schema'; @@ -49,7 +48,7 @@ describe('feature-announcements.toml', () => { expect(schema.required).toEqual(['id', 'title', 'changelogUrl', 'features']); expect(schema.properties?.hero?.enum).toEqual([...FEATURE_ANNOUNCEMENT_HEROES]); expect(schema.$defs?.feature?.additionalProperties).toBe(false); - expect(schema.$defs?.feature?.properties?.icon?.enum).toEqual([...FEATURE_ANNOUNCEMENT_ICONS]); + expect(schema.$defs?.feature?.required).toEqual(['title', 'description']); expect(schema.$defs?.cta?.additionalProperties).toBe(false); expect(schema.$defs?.cta?.properties?.action?.enum).toEqual([ ...FEATURE_ANNOUNCEMENT_CTA_ACTIONS, diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts index accb74fc2e..24c3b7f30a 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -14,7 +14,6 @@ const sampleManifest = { minAppVersion: '1.1.27', features: [ { - icon: 'calendar-clock', title: 'Run agents on a schedule', description: 'Launch coding agents on a cron.', }, @@ -41,6 +40,21 @@ describe('feature announcement schema', () => { }); }); + it('rejects legacy feature icon fields', () => { + expect( + parseFeatureAnnouncementManifest({ + ...sampleManifest, + features: [ + { + icon: 'calendar-clock', + title: 'Run agents on a schedule', + description: 'Launch coding agents on a cron.', + }, + ], + }) + ).toBeNull(); + }); + it('rejects invalid CTA definitions', () => { expect( parseFeatureAnnouncementManifest({ diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index f1d664176f..4d353eff04 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -2,18 +2,14 @@ import z from 'zod'; import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS, FEATURE_ANNOUNCEMENT_HEROES, - FEATURE_ANNOUNCEMENT_ICONS, } from './constants'; -const featureAnnouncementIconSchema = z.enum(FEATURE_ANNOUNCEMENT_ICONS); - const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); const featureAnnouncementHeroSchema = z.enum(FEATURE_ANNOUNCEMENT_HEROES); const featureAnnouncementFeatureSchema = z .object({ - icon: featureAnnouncementIconSchema, title: z.string().min(1), description: z.string().min(1), }) @@ -48,7 +44,6 @@ export const featureAnnouncementManifestSchema = z export type FeatureAnnouncementHero = z.infer; -export type FeatureAnnouncementIcon = z.infer; export type FeatureAnnouncementFeature = z.infer; export type FeatureAnnouncementCta = z.infer; export type FeatureAnnouncementManifest = z.infer; From 595075db50f85003ad6915641bfab97e1893cce3 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:14:48 +0200 Subject: [PATCH 17/21] style(announcements): format schema import --- .../src/shared/feature-announcements/schema.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 4d353eff04..624d4bcdf4 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -1,8 +1,5 @@ import z from 'zod'; -import { - FEATURE_ANNOUNCEMENT_CTA_ACTIONS, - FEATURE_ANNOUNCEMENT_HEROES, -} from './constants'; +import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS, FEATURE_ANNOUNCEMENT_HEROES } from './constants'; const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); From 14aa9524ead5928640d6a4a0f73883b45f06e6c4 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:21:00 +0200 Subject: [PATCH 18/21] test(announcements): type schema required field --- .../feature-announcements/feature-announcements.toml.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts index 829673ce60..b654ce6595 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -38,6 +38,7 @@ describe('feature-announcements.toml', () => { $defs?: { feature?: { additionalProperties?: boolean; + required?: string[]; properties?: Record; }; cta?: { additionalProperties?: boolean; properties?: Record }; From ac9ac5716d2313bb54ca1d6716dbb945c9dc053a Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:48:09 +0200 Subject: [PATCH 19/21] feat: add sidebar changelog toast --- apps/emdash-desktop/src/renderer/App.tsx | 4 - .../src/renderer/app/workspace.tsx | 2 - .../feature-announcement-launcher.tsx | 34 ---- .../feature-announcement-sidebar-toast.tsx | 157 ++++++++++++++++++ .../feature-announcement-store.test.ts | 7 +- .../feature-announcement-store.ts | 34 ++-- .../feature-announcement-toast.tsx | 117 ------------- .../feature-announcement-toaster.tsx | 23 --- .../present-feature-announcement.ts | 22 --- .../components/AnnouncementDevControls.tsx | 12 +- .../features/sidebar/left-sidebar.tsx | 3 + 11 files changed, 179 insertions(+), 236 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx create mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts diff --git a/apps/emdash-desktop/src/renderer/App.tsx b/apps/emdash-desktop/src/renderer/App.tsx index fcacbff576..551a265f97 100644 --- a/apps/emdash-desktop/src/renderer/App.tsx +++ b/apps/emdash-desktop/src/renderer/App.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useState } from 'react'; import { AppMenuEvents } from './app/app-menu-events'; import { WelcomeScreen } from './app/welcome'; import { Workspace } from './app/workspace'; -import { FeatureAnnouncementLauncher } from './features/feature-announcements/feature-announcement-launcher'; import { IntegrationsProvider } from './features/integrations/integrations-provider'; import { Onboarding } from './features/onboarding/onboarding'; import { FramelessTitlebarOverlay } from './lib/components/titlebar/window-controls'; @@ -92,8 +91,6 @@ function AppContent() { return ; }; - const workspaceVisible = !isLoading && view === 'workspace'; - return ( @@ -102,7 +99,6 @@ function AppContent() { - diff --git a/apps/emdash-desktop/src/renderer/app/workspace.tsx b/apps/emdash-desktop/src/renderer/app/workspace.tsx index 45e2a2309c..50040ccee3 100644 --- a/apps/emdash-desktop/src/renderer/app/workspace.tsx +++ b/apps/emdash-desktop/src/renderer/app/workspace.tsx @@ -1,4 +1,3 @@ -import { FeatureAnnouncementToaster } from '@renderer/features/feature-announcements/feature-announcement-toaster'; import { LeftSidebar } from '@renderer/features/sidebar/left-sidebar'; import { CommandShortcutBinder } from '@renderer/lib/commands/command-shortcut-binder'; import { AppKeyboardShortcuts } from '@renderer/lib/components/app-keyboard-shortcuts'; @@ -32,7 +31,6 @@ export function Workspace() { } /> - ); } diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx deleted file mode 100644 index 0b04259c47..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; -import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; -import { appState } from '@renderer/lib/stores/app-state'; - -export const FeatureAnnouncementLauncher = observer(function FeatureAnnouncementLauncher({ - active, -}: { - active: boolean; -}) { - const store = appState.featureAnnouncements; - - useEffect(() => { - const manifest = store.manifest; - if ( - !active || - store.isPreview || - store.status !== 'ready' || - !store.shouldPresent || - !manifest - ) { - return; - } - - const timer = setTimeout(() => { - void store.markPresented(); - presentFeatureAnnouncement(manifest); - }, 800); - - return () => clearTimeout(timer); - }, [active, store, store.status, store.shouldPresent, store.manifest, store.isPreview]); - - return null; -}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx new file mode 100644 index 0000000000..5669dfc5a5 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx @@ -0,0 +1,157 @@ +import { ChevronRight, XIcon } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import { useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; +import { useWorkspaceLayoutContext } from '@renderer/lib/layout/layout-provider'; +import { appState } from '@renderer/lib/stores/app-state'; +import { cn } from '@renderer/utils/utils'; +import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; + +const SIDEBAR_SELECTOR = '[data-emdash-left-sidebar]'; + +type SidebarAnchor = { + left: number; + width: number; + bottom: number; +}; + +function handleCtaAction(action: FeatureAnnouncementCtaAction): void { + switch (action) { + case 'open-automations': + appState.navigation.navigate('automations'); + break; + } +} + +function useSidebarAnchor(enabled: boolean): SidebarAnchor | null { + const [anchor, setAnchor] = useState(null); + + useLayoutEffect(() => { + if (!enabled) { + setAnchor(null); + return; + } + + const sidebar = document.querySelector(SIDEBAR_SELECTOR); + if (!sidebar) { + setAnchor(null); + return; + } + + const sync = () => { + const rect = sidebar.getBoundingClientRect(); + if (rect.width <= 0) { + setAnchor(null); + return; + } + + setAnchor({ + left: rect.left, + width: rect.width, + bottom: window.innerHeight - rect.bottom, + }); + }; + + sync(); + + const observer = new ResizeObserver(sync); + observer.observe(sidebar); + window.addEventListener('resize', sync); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', sync); + }; + }, [enabled]); + + return anchor; +} + +export const FeatureAnnouncementSidebarToast = observer(function FeatureAnnouncementSidebarToast() { + const store = appState.featureAnnouncements; + const { isLeftOpen } = useWorkspaceLayoutContext(); + const manifest = store.manifest; + const enabled = + isLeftOpen && store.status === 'ready' && Boolean(manifest) && store.shouldPresent; + const anchor = useSidebarAnchor(enabled); + + if (!enabled || !manifest || !anchor) { + return null; + } + + const handleDismiss = () => { + void store.dismiss(); + }; + + const handleChangelog = () => { + confirmOpenExternalLink(manifest.changelogUrl); + }; + + const handleTitleClick = () => { + if (manifest.cta?.action) { + handleCtaAction(manifest.cta.action); + handleDismiss(); + return; + } + + if (manifest.cta?.url) { + confirmOpenExternalLink(manifest.cta.url); + handleDismiss(); + } + }; + + const titleIsInteractive = Boolean(manifest.cta?.action || manifest.cta?.url); + + return createPortal( +
+
+
+ +

{manifest.eyebrow}

+ {titleIsInteractive ? ( + + ) : ( +

{manifest.title}

+ )} +
+ +
+
, + document.body + ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index a4e365a7f5..534975b690 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -68,18 +68,19 @@ describe('FeatureAnnouncementStore', () => { it('persists dismissed announcement ids', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); - await store.markPresented(); + await store.dismiss(); expect(settings).toEqual({ initialized: true, dismissedIds: ['test-announcement'] }); }); - it('does not persist preview presentation', async () => { + it('does not persist preview dismissal', async () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); store.isPreview = true; - await store.markPresented(); + await store.dismiss(); expect(settings).toEqual({ initialized: false, dismissedIds: [] }); + expect(store.isPreview).toBe(false); }); it('initializes fresh-install dismissal state when manifest fetch fails', async () => { diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index ba6b5b3d26..39a70089ed 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -12,25 +12,22 @@ export class FeatureAnnouncementStore { status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; isPreview = false; dismissedIds = new Set(); - private hasPresented = false; constructor() { - makeObservable(this, { + makeObservable(this, { manifest: observable, status: observable, isPreview: observable, dismissedIds: observable, - hasPresented: observable, shouldPresent: computed, - markPresented: action, - resetPresentation: action, setManifest: action, setStatus: action, + dismiss: action, }); } get shouldPresent(): boolean { - if (!this.manifest || this.hasPresented) return false; + if (!this.manifest) return false; if (this.isPreview) return true; return !this.dismissedIds.has(this.manifest.id); } @@ -43,24 +40,21 @@ export class FeatureAnnouncementStore { this.status = status; } - resetPresentation(): void { - this.hasPresented = false; - } - - async markPresented(): Promise { + async dismiss(): Promise { if (!this.manifest) return; - this.hasPresented = true; - if (!this.isPreview) { - const announcementId = this.manifest.id; - this.dismissedIds = new Set([...this.dismissedIds, announcementId]); - await markAnnouncementDismissed(announcementId); + + if (this.isPreview) { + this.isPreview = false; + return; } + + const announcementId = this.manifest.id; + this.dismissedIds = new Set([...this.dismissedIds, announcementId]); + await markAnnouncementDismissed(announcementId); } async replayPreview(): Promise { await this.refresh({ preview: true }); - if (!this.manifest) return; - this.resetPresentation(); } async clearDismissal(): Promise { @@ -68,7 +62,6 @@ export class FeatureAnnouncementStore { const announcementId = this.manifest.id; this.dismissedIds = new Set([...this.dismissedIds].filter((id) => id !== announcementId)); await clearAnnouncementDismissal(announcementId); - this.resetPresentation(); } async start(options?: { isFreshInstall?: boolean }): Promise { @@ -96,9 +89,6 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; this.isPreview = Boolean(options?.preview); - if (!options?.preview) { - this.resetPresentation(); - } }); try { diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx deleted file mode 100644 index 01433787a0..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { ArrowUpRight, XIcon } from 'lucide-react'; -import { toast } from 'sonner'; -import { - FeatureAnnouncementMediaArea, - resolveFeatureAnnouncementMedia, -} from '@renderer/features/feature-announcements/feature-announcement-media'; -import { FEATURE_ANNOUNCEMENT_TOASTER_ID } from '@renderer/features/feature-announcements/feature-announcement-toaster'; -import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; -import { Button } from '@renderer/lib/ui/button'; -import { cn } from '@renderer/utils/utils'; -import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -const CUSTOM_TOAST_CLASSNAMES = { - toast: '!border-none !bg-transparent !p-0 !shadow-none', -}; - -type FeatureAnnouncementToastOptions = { - onAction?: (action: FeatureAnnouncementCtaAction) => void; - onDismiss?: () => void; -}; - -export function showFeatureAnnouncementToast( - manifest: FeatureAnnouncementManifest, - options?: FeatureAnnouncementToastOptions -): void { - toast.custom( - (id) => , - { - duration: Infinity, - classNames: CUSTOM_TOAST_CLASSNAMES, - toasterId: FEATURE_ANNOUNCEMENT_TOASTER_ID, - } - ); -} - -function FeatureAnnouncementToastCard({ - manifest, - toastId, - options, -}: { - manifest: FeatureAnnouncementManifest; - toastId: string | number; - options?: FeatureAnnouncementToastOptions; -}) { - const media = resolveFeatureAnnouncementMedia(manifest); - const dismiss = () => { - toast.dismiss(toastId); - options?.onDismiss?.(); - }; - - const handleLearnMore = () => { - const url = manifest.learnMoreUrl ?? manifest.changelogUrl; - confirmOpenExternalLink(url); - }; - - const handleCta = () => { - if (manifest.cta?.url) { - confirmOpenExternalLink(manifest.cta.url); - dismiss(); - return; - } - - if (manifest.cta?.action) { - options?.onAction?.(manifest.cta.action); - } - dismiss(); - }; - - return ( -
- {media && } - -
-
-

{manifest.eyebrow}

-

{manifest.title}

-
-
    - {manifest.features.map((feature, index) => ( -
  • -

    {feature.title}

    -

    {feature.description}

    -
  • - ))} -
-
-
- - {manifest.cta ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx deleted file mode 100644 index 2824594b71..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toaster.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Toaster as SonnerToaster } from 'sonner'; -import { useTheme } from '@renderer/lib/hooks/useTheme'; - -export const FEATURE_ANNOUNCEMENT_TOASTER_ID = 'feature-announcements'; - -export function FeatureAnnouncementToaster() { - const { effectiveTheme } = useTheme(); - const theme = effectiveTheme === 'emlight' ? 'light' : 'dark'; - - return ( - - ); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts deleted file mode 100644 index 0f835d9744..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/present-feature-announcement.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { showFeatureAnnouncementToast } from '@renderer/features/feature-announcements/feature-announcement-toast'; -import { appState } from '@renderer/lib/stores/app-state'; -import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -function handleCtaAction(action: FeatureAnnouncementCtaAction): void { - switch (action) { - case 'open-automations': - appState.navigation.navigate('automations'); - break; - } -} - -export function presentFeatureAnnouncement( - manifest: FeatureAnnouncementManifest, - options?: { onDismiss?: () => void } -): void { - showFeatureAnnouncementToast(manifest, { - onAction: handleCtaAction, - onDismiss: options?.onDismiss, - }); -} diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx index 157b44fb7b..560e588481 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -1,7 +1,6 @@ import { Megaphone, RotateCcw } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; -import { presentFeatureAnnouncement } from '@renderer/features/feature-announcements/present-feature-announcement'; import { appState } from '@renderer/lib/stores/app-state'; import { Button } from '@renderer/lib/ui/button'; import { SettingRow } from './SettingRow'; @@ -15,7 +14,7 @@ export const AnnouncementDevControls = observer( return ( @@ -23,13 +22,8 @@ export const AnnouncementDevControls = observer( type="button" size="sm" variant="outline" - onClick={async () => { - await store.replayPreview(); - if (!store.manifest) return; - await store.markPresented(); - presentFeatureAnnouncement(store.manifest, { - onDismiss: () => store.resetPresentation(), - }); + onClick={() => { + void store.replayPreview(); }} > diff --git a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx index d9c51e5a74..7ea7bff10a 100644 --- a/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx +++ b/apps/emdash-desktop/src/renderer/features/sidebar/left-sidebar.tsx @@ -1,6 +1,7 @@ import { Clock, FolderInput, Library, MessageSquareShare, Settings } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import React from 'react'; +import { FeatureAnnouncementSidebarToast } from '@renderer/features/feature-announcements/feature-announcement-sidebar-toast'; import { isCurrentView, useNavigate, @@ -35,6 +36,7 @@ export const LeftSidebar: React.FC = observer(function LeftSidebar() { return (
+
); }); From ba55d31df45b73585cbd11804d399de7e1fe0e2a Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:43:27 +0200 Subject: [PATCH 20/21] fix(announcements): gate toast display --- .../feature-announcement-sidebar-toast.tsx | 4 ++-- .../feature-announcement-store.test.ts | 13 ++++++++++++- .../feature-announcement-store.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx index 5669dfc5a5..8c5baa0787 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-sidebar-toast.tsx @@ -2,8 +2,8 @@ import { ChevronRight, XIcon } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useLayoutEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; import { useWorkspaceLayoutContext } from '@renderer/lib/layout/layout-provider'; +import { confirmOpenExternalLink } from '@renderer/lib/open-external-link'; import { appState } from '@renderer/lib/stores/app-state'; import { cn } from '@renderer/utils/utils'; import type { FeatureAnnouncementCtaAction } from '@shared/feature-announcements/constants'; @@ -114,7 +114,7 @@ export const FeatureAnnouncementSidebarToast = observer(function FeatureAnnounce >
diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index 534975b690..ffe2254f57 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -53,14 +53,16 @@ describe('FeatureAnnouncementStore', () => { it('does not present dismissed announcements', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); + store.presentationReady = true; store.dismissForTest(manifest.id); expect(store.shouldPresent).toBe(false); }); - it('presents unseen announcements', () => { + it('presents unseen announcements after dismissal state loads', () => { const store = new FeatureAnnouncementStore(); store.setManifest(manifest); + store.presentationReady = true; expect(store.shouldPresent).toBe(true); }); @@ -81,6 +83,14 @@ describe('FeatureAnnouncementStore', () => { expect(settings).toEqual({ initialized: false, dismissedIds: [] }); expect(store.isPreview).toBe(false); + expect(store.shouldPresent).toBe(false); + }); + + it('does not present before dismissal state is ready', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + + expect(store.shouldPresent).toBe(false); }); it('initializes fresh-install dismissal state when manifest fetch fails', async () => { @@ -91,5 +101,6 @@ describe('FeatureAnnouncementStore', () => { expect(store.status).toBe('error'); expect(settings).toEqual({ initialized: true, dismissedIds: [] }); + expect(store.presentationReady).toBe(true); }); }); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts index 39a70089ed..3aca193b0f 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -12,6 +12,8 @@ export class FeatureAnnouncementStore { status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; isPreview = false; dismissedIds = new Set(); + presentationReady = false; + dismissedPreviewId: string | null = null; constructor() { makeObservable(this, { @@ -19,6 +21,8 @@ export class FeatureAnnouncementStore { status: observable, isPreview: observable, dismissedIds: observable, + presentationReady: observable, + dismissedPreviewId: observable, shouldPresent: computed, setManifest: action, setStatus: action, @@ -28,7 +32,9 @@ export class FeatureAnnouncementStore { get shouldPresent(): boolean { if (!this.manifest) return false; + if (this.dismissedPreviewId === this.manifest.id) return false; if (this.isPreview) return true; + if (!this.presentationReady) return false; return !this.dismissedIds.has(this.manifest.id); } @@ -44,6 +50,7 @@ export class FeatureAnnouncementStore { if (!this.manifest) return; if (this.isPreview) { + this.dismissedPreviewId = this.manifest.id; this.isPreview = false; return; } @@ -61,10 +68,15 @@ export class FeatureAnnouncementStore { if (!this.manifest) return; const announcementId = this.manifest.id; this.dismissedIds = new Set([...this.dismissedIds].filter((id) => id !== announcementId)); + this.dismissedPreviewId = null; await clearAnnouncementDismissal(announcementId); } async start(options?: { isFreshInstall?: boolean }): Promise { + runInAction(() => { + this.presentationReady = false; + }); + await Promise.all([this.refresh(), this.loadDismissalState()]); if (options?.isFreshInstall) { @@ -74,6 +86,10 @@ export class FeatureAnnouncementStore { }); await this.loadDismissalState(); } + + runInAction(() => { + this.presentationReady = true; + }); } async loadDismissalState(): Promise { @@ -89,6 +105,9 @@ export class FeatureAnnouncementStore { runInAction(() => { this.status = 'loading'; this.isPreview = Boolean(options?.preview); + if (options?.preview) { + this.dismissedPreviewId = null; + } }); try { From a050b2624cbb27f558f8b014390871f9a240b1e2 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:49:34 +0200 Subject: [PATCH 21/21] refactor(announcements): trim toast manifest --- .../feature-announcements.schema.json | 46 +---------- .../emdash-desktop/feature-announcements.toml | 24 +----- .../feature-announcement-media.test.ts | 43 ---------- .../feature-announcement-media.tsx | 78 ------------------- .../feature-announcement-store.test.ts | 6 -- .../shared/feature-announcements/constants.ts | 2 - .../feature-announcements.toml.test.ts | 16 +--- .../feature-announcements/schema.test.ts | 22 ++---- .../shared/feature-announcements/schema.ts | 19 +---- 9 files changed, 12 insertions(+), 244 deletions(-) delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts delete mode 100644 apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json index 7584071263..d598e74d03 100644 --- a/apps/emdash-desktop/feature-announcements.schema.json +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -1,10 +1,10 @@ { "$id": "feature-announcements.schema.json", "title": "Emdash feature announcement manifest", - "description": "In-app feature highlight shown as a toast. Runtime validation uses the Zod schema in src/shared/feature-announcements/schema.ts — keep both in sync.", + "description": "In-app feature announcement shown as a bottom-left sidebar toast. Runtime validation uses the Zod schema in src/shared/feature-announcements/schema.ts — keep both in sync.", "type": "object", "additionalProperties": false, - "required": ["id", "title", "changelogUrl", "features"], + "required": ["id", "title", "changelogUrl"], "properties": { "enabled": { "type": "boolean", @@ -25,66 +25,24 @@ "type": "string", "minLength": 1 }, - "hero": { - "type": "string", - "enum": ["automations"], - "description": "Built-in coded hero graphic shipped with the app." - }, - "image": { - "type": "string", - "format": "uri", - "description": "Remote hero image URL. Takes precedence over hero when both are set." - }, "changelogUrl": { "type": "string", "format": "uri" }, - "learnMoreUrl": { - "type": "string", - "format": "uri" - }, "minAppVersion": { "type": "string", "minLength": 1, "description": "Semver minimum; older app versions ignore this manifest." }, - "features": { - "type": "array", - "minItems": 1, - "maxItems": 4, - "items": { - "$ref": "#/$defs/feature" - } - }, "cta": { "$ref": "#/$defs/cta" } }, "$defs": { - "feature": { - "type": "object", - "additionalProperties": false, - "required": ["title", "description"], - "properties": { - "title": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string", - "minLength": 1 - } - } - }, "cta": { "type": "object", "additionalProperties": false, - "required": ["label"], "properties": { - "label": { - "type": "string", - "minLength": 1 - }, "action": { "type": "string", "enum": ["open-automations"] diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml index a9128d0e82..83851daff3 100644 --- a/apps/emdash-desktop/feature-announcements.toml +++ b/apps/emdash-desktop/feature-announcements.toml @@ -1,42 +1,22 @@ #:schema ./feature-announcements.schema.json # -# In-app feature highlight manifest. +# In-app feature announcement shown as a bottom-left sidebar toast. # # Edit this file on GitHub to announce major features without shipping a new app -# release. Set `enabled = false` to hide the card while keeping content around +# release. Set `enabled = false` to hide the toast while keeping content around # for the next launch. # # Runtime validation: src/shared/feature-announcements/schema.ts (Zod). # Editor/CI schema: feature-announcements.schema.json (keep in sync with Zod). # # The app fetches this file from the main branch at startup. -# -# Media — pick one (`image` wins if both are set): -# hero = built-in coded UI mock shipped with the app -# image = remote hero image URL, rendered full-bleed in the dark header area -# -# Examples: -# hero = "automations" -# image = "https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/build/feature-announcements/automations-hero.png" enabled = true id = "automations-2026-06" -hero = "automations" -# image = "https://example.com/automations-hero.png" eyebrow = "Now available" title = "Emdash Automations" changelogUrl = "https://emdash.sh/changelog" -learnMoreUrl = "https://docs.emdash.sh" minAppVersion = "1.1.27" -[[features]] -title = "Run agents on a schedule" -description = "Launch coding agents on a cron — nightly dependency audits, issue triage, recurring chores." - -[[features]] -title = "Every run in one place" -description = "Follow live status and review the results of past runs without leaving the app." - [cta] -label = "Open Automations" action = "open-automations" diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts deleted file mode 100644 index 8a7bd98a0f..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { resolveFeatureAnnouncementMedia } from '@renderer/features/feature-announcements/feature-announcement-media'; -import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; - -const baseManifest: FeatureAnnouncementManifest = { - enabled: true, - id: 'test', - eyebrow: 'Now available', - title: 'Test', - changelogUrl: 'https://emdash.sh/changelog', - features: [{ title: 'Feature', description: 'Description.' }], -}; - -describe('resolveFeatureAnnouncementMedia', () => { - it('prefers remote image URLs over built-in hero components', () => { - expect( - resolveFeatureAnnouncementMedia({ - ...baseManifest, - hero: 'automations', - image: 'https://emdash.sh/media/automations-hero.png', - }) - ).toEqual({ - kind: 'image', - url: 'https://emdash.sh/media/automations-hero.png', - }); - }); - - it('falls back to coded hero components when no image is set', () => { - expect( - resolveFeatureAnnouncementMedia({ - ...baseManifest, - hero: 'automations', - }) - ).toEqual({ - kind: 'hero', - hero: 'automations', - }); - }); - - it('returns null when neither image nor hero is configured', () => { - expect(resolveFeatureAnnouncementMedia(baseManifest)).toBeNull(); - }); -}); diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx deleted file mode 100644 index 31b9116a17..0000000000 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-media.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { ReactNode } from 'react'; -import { cn } from '@renderer/utils/utils'; -import type { - FeatureAnnouncementHero, - FeatureAnnouncementManifest, -} from '@shared/feature-announcements/schema'; - -function AutomationsHeroGraphic() { - return ( -
-
- Nightly dependency audit - -
-
- Triage new issues - daily 9:00 -
-
- Weekly changelog draft - Mon 8:00 -
-
- ); -} - -const HERO_COMPONENTS: Record ReactNode> = { - automations: AutomationsHeroGraphic, -}; - -export type FeatureAnnouncementMedia = - | { kind: 'image'; url: string } - | { kind: 'hero'; hero: FeatureAnnouncementHero }; - -export function resolveFeatureAnnouncementMedia( - manifest: FeatureAnnouncementManifest -): FeatureAnnouncementMedia | null { - if (manifest.image) { - return { kind: 'image', url: manifest.image }; - } - - if (manifest.hero) { - return { kind: 'hero', hero: manifest.hero }; - } - - return null; -} - -export function FeatureAnnouncementMediaArea({ media }: { media: FeatureAnnouncementMedia }) { - if (media.kind === 'image') { - return ( -
- -
-
- ); - } - - const HeroGraphic = HERO_COMPONENTS[media.hero]; - if (!HeroGraphic) return null; - - return ( -
-
-
- -
-
- ); -} diff --git a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts index ffe2254f57..f410f98872 100644 --- a/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -23,12 +23,6 @@ const manifest: FeatureAnnouncementManifest = { eyebrow: 'Now available', title: 'Test Feature', changelogUrl: 'https://emdash.sh/changelog', - features: [ - { - title: 'Something new', - description: 'A highlighted capability.', - }, - ], }; describe('FeatureAnnouncementStore', () => { diff --git a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts index d043a5321f..b9219a01b0 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/constants.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -3,8 +3,6 @@ export const FEATURE_ANNOUNCEMENT_MANIFEST_FILENAME = 'feature-announcements.tom export const FEATURE_ANNOUNCEMENT_MANIFEST_URL = 'https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/feature-announcements.toml'; -export const FEATURE_ANNOUNCEMENT_HEROES = ['automations'] as const; - export const FEATURE_ANNOUNCEMENT_CTA_ACTIONS = ['open-automations'] as const; export type FeatureAnnouncementCtaAction = (typeof FEATURE_ANNOUNCEMENT_CTA_ACTIONS)[number]; diff --git a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts index b654ce6595..6c76e125e6 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -3,10 +3,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse } from 'smol-toml'; import { describe, expect, it } from 'vitest'; -import { - FEATURE_ANNOUNCEMENT_CTA_ACTIONS, - FEATURE_ANNOUNCEMENT_HEROES, -} from '@shared/feature-announcements/constants'; +import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } from '@shared/feature-announcements/constants'; import { featureAnnouncementManifestSchema } from '@shared/feature-announcements/schema'; const manifestPath = join( @@ -34,22 +31,13 @@ describe('feature-announcements.toml', () => { const schema = JSON.parse(await readFile(editorSchemaPath, 'utf8')) as { additionalProperties?: boolean; required?: string[]; - properties?: Record; $defs?: { - feature?: { - additionalProperties?: boolean; - required?: string[]; - properties?: Record; - }; cta?: { additionalProperties?: boolean; properties?: Record }; }; }; expect(schema.additionalProperties).toBe(false); - expect(schema.required).toEqual(['id', 'title', 'changelogUrl', 'features']); - expect(schema.properties?.hero?.enum).toEqual([...FEATURE_ANNOUNCEMENT_HEROES]); - expect(schema.$defs?.feature?.additionalProperties).toBe(false); - expect(schema.$defs?.feature?.required).toEqual(['title', 'description']); + expect(schema.required).toEqual(['id', 'title', 'changelogUrl']); expect(schema.$defs?.cta?.additionalProperties).toBe(false); expect(schema.$defs?.cta?.properties?.action?.enum).toEqual([ ...FEATURE_ANNOUNCEMENT_CTA_ACTIONS, diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts index 24c3b7f30a..7725f73919 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -10,16 +10,8 @@ const sampleManifest = { eyebrow: 'Now available', title: 'Emdash Automations', changelogUrl: 'https://emdash.sh/changelog', - learnMoreUrl: 'https://docs.emdash.sh', minAppVersion: '1.1.27', - features: [ - { - title: 'Run agents on a schedule', - description: 'Launch coding agents on a cron.', - }, - ], cta: { - label: 'Open Automations', action: 'open-automations', }, }; @@ -40,17 +32,13 @@ describe('feature announcement schema', () => { }); }); - it('rejects legacy feature icon fields', () => { + it('rejects removed card-only fields', () => { expect( parseFeatureAnnouncementManifest({ ...sampleManifest, - features: [ - { - icon: 'calendar-clock', - title: 'Run agents on a schedule', - description: 'Launch coding agents on a cron.', - }, - ], + hero: 'automations', + features: [{ title: 'Run agents on a schedule', description: 'Launch agents on a cron.' }], + learnMoreUrl: 'https://docs.emdash.sh', }) ).toBeNull(); }); @@ -59,7 +47,7 @@ describe('feature announcement schema', () => { expect( parseFeatureAnnouncementManifest({ ...sampleManifest, - cta: { label: 'Broken', action: 'open-automations', url: 'https://example.com' }, + cta: { action: 'open-automations', url: 'https://example.com' }, }) ).toBeNull(); }); diff --git a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts index 624d4bcdf4..89f578b743 100644 --- a/apps/emdash-desktop/src/shared/feature-announcements/schema.ts +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -1,20 +1,10 @@ import z from 'zod'; -import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS, FEATURE_ANNOUNCEMENT_HEROES } from './constants'; +import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } from './constants'; const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); -const featureAnnouncementHeroSchema = z.enum(FEATURE_ANNOUNCEMENT_HEROES); - -const featureAnnouncementFeatureSchema = z - .object({ - title: z.string().min(1), - description: z.string().min(1), - }) - .strict(); - const featureAnnouncementCtaSchema = z .object({ - label: z.string().min(1), action: featureAnnouncementCtaActionSchema.optional(), url: z.url().optional(), }) @@ -29,19 +19,12 @@ export const featureAnnouncementManifestSchema = z id: z.string().min(1), eyebrow: z.string().min(1).default('Now available'), title: z.string().min(1), - hero: featureAnnouncementHeroSchema.optional(), - image: z.url().optional(), changelogUrl: z.url(), - learnMoreUrl: z.url().optional(), minAppVersion: z.string().min(1).optional(), - features: z.array(featureAnnouncementFeatureSchema).min(1).max(4), cta: featureAnnouncementCtaSchema.optional(), }) .strict(); -export type FeatureAnnouncementHero = z.infer; - -export type FeatureAnnouncementFeature = z.infer; export type FeatureAnnouncementCta = z.infer; export type FeatureAnnouncementManifest = z.infer;