diff --git a/apps/emdash-desktop/feature-announcements.schema.json b/apps/emdash-desktop/feature-announcements.schema.json new file mode 100644 index 0000000000..d598e74d03 --- /dev/null +++ b/apps/emdash-desktop/feature-announcements.schema.json @@ -0,0 +1,71 @@ +{ + "$id": "feature-announcements.schema.json", + "title": "Emdash feature announcement manifest", + "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"], + "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 + }, + "changelogUrl": { + "type": "string", + "format": "uri" + }, + "minAppVersion": { + "type": "string", + "minLength": 1, + "description": "Semver minimum; older app versions ignore this manifest." + }, + "cta": { + "$ref": "#/$defs/cta" + } + }, + "$defs": { + "cta": { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "enum": ["open-automations"] + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "oneOf": [ + { + "required": ["action"], + "not": { + "required": ["url"] + } + }, + { + "required": ["url"], + "not": { + "required": ["action"] + } + } + ] + } + } +} diff --git a/apps/emdash-desktop/feature-announcements.toml b/apps/emdash-desktop/feature-announcements.toml new file mode 100644 index 0000000000..83851daff3 --- /dev/null +++ b/apps/emdash-desktop/feature-announcements.toml @@ -0,0 +1,22 @@ +#:schema ./feature-announcements.schema.json +# +# 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 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. + +enabled = true +id = "automations-2026-06" +eyebrow = "Now available" +title = "Emdash Automations" +changelogUrl = "https://emdash.sh/changelog" +minAppVersion = "1.1.27" + +[cta] +action = "open-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/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/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/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..8c5baa0787 --- /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 { 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'; + +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-state.test.ts b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts new file mode 100644 index 0000000000..63936e1aa0 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + clearAnnouncementDismissal, + initializeFreshInstallAnnouncement, + markAnnouncementDismissed, + readAnnouncementDismissalState, +} from '@renderer/features/feature-announcements/feature-announcement-state'; +import type { AnnouncementSettings } from '@shared/core/app-settings'; + +function makeSettingsClient( + initial: AnnouncementSettings = { initialized: false, dismissedIds: [] } +) { + let settings = initial; + return { + 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', async () => { + const client = makeSettingsClient(); + await initializeFreshInstallAnnouncement( + { + announcementId: 'automations-2026-06', + isFreshInstall: true, + }, + client + ); + + expect(client.read()).toEqual({ + initialized: true, + dismissedIds: ['automations-2026-06'], + }); + }); + + 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( + { + announcementId: 'automations-2026-06', + isFreshInstall: true, + }, + client + ); + + expect(client.read()).toEqual({ initialized: true, dismissedIds: ['older'] }); + }); + + 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', 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', async () => { + const client = makeSettingsClient(); + await markAnnouncementDismissed('automations-2026-06', client); + await clearAnnouncementDismissal('automations-2026-06', client); + + expect(client.read()).toEqual({ initialized: true, dismissedIds: [] }); + }); + + 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 new file mode 100644 index 0000000000..3c34fd4100 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts @@ -0,0 +1,98 @@ +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 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 async function initializeFreshInstallAnnouncement( + options: { + announcementId?: string; + isFreshInstall: boolean; + }, + client: AnnouncementSettingsClient = appSettingsClient +): Promise { + if (!options.isFreshInstall) return; + + const settings = await readAnnouncementDismissalState(client); + if (settings.initialized) return; + + await writeAnnouncementDismissalState( + { + initialized: true, + dismissedIds: options.announcementId ? [options.announcementId] : [], + }, + client + ); +} + +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 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 new file mode 100644 index 0000000000..f410f98872 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FeatureAnnouncementStore } from '@renderer/features/feature-announcements/feature-announcement-store'; +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(), + }, + featureAnnouncements: { + getCurrent: vi.fn(), + preview: vi.fn(), + }, + }, +})); + +const manifest: FeatureAnnouncementManifest = { + enabled: true, + id: 'test-announcement', + eyebrow: 'Now available', + title: 'Test Feature', + changelogUrl: 'https://emdash.sh/changelog', +}; + +describe('FeatureAnnouncementStore', () => { + let settings: AnnouncementSettings; + + beforeEach(() => { + 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; + }); + 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', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.presentationReady = true; + store.dismissForTest(manifest.id); + + expect(store.shouldPresent).toBe(false); + }); + + it('presents unseen announcements after dismissal state loads', () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.presentationReady = true; + + expect(store.shouldPresent).toBe(true); + }); + + it('persists dismissed announcement ids', async () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + await store.dismiss(); + + expect(settings).toEqual({ initialized: true, dismissedIds: ['test-announcement'] }); + }); + + it('does not persist preview dismissal', async () => { + const store = new FeatureAnnouncementStore(); + store.setManifest(manifest); + store.isPreview = true; + await store.dismiss(); + + 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 () => { + 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: [] }); + 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 new file mode 100644 index 0000000000..3aca193b0f --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts @@ -0,0 +1,138 @@ +import { action, computed, makeObservable, observable, runInAction } from 'mobx'; +import { + clearAnnouncementDismissal, + initializeFreshInstallAnnouncement, + markAnnouncementDismissed, + readAnnouncementDismissalState, +} from '@renderer/features/feature-announcements/feature-announcement-state'; +import type { FeatureAnnouncementManifest } from '@shared/feature-announcements/schema'; + +export class FeatureAnnouncementStore { + manifest: FeatureAnnouncementManifest | null = null; + status: 'idle' | 'loading' | 'ready' | 'error' = 'idle'; + isPreview = false; + dismissedIds = new Set(); + presentationReady = false; + dismissedPreviewId: string | null = null; + + constructor() { + makeObservable(this, { + manifest: observable, + status: observable, + isPreview: observable, + dismissedIds: observable, + presentationReady: observable, + dismissedPreviewId: observable, + shouldPresent: computed, + setManifest: action, + setStatus: action, + dismiss: action, + }); + } + + 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); + } + + setManifest(manifest: FeatureAnnouncementManifest | null): void { + this.manifest = manifest; + } + + setStatus(status: FeatureAnnouncementStore['status']): void { + this.status = status; + } + + async dismiss(): Promise { + if (!this.manifest) return; + + if (this.isPreview) { + this.dismissedPreviewId = this.manifest.id; + 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 }); + } + + async clearDismissal(): Promise { + 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) { + await initializeFreshInstallAnnouncement({ + announcementId: this.manifest?.id, + isFreshInstall: true, + }); + await this.loadDismissalState(); + } + + runInAction(() => { + this.presentationReady = true; + }); + } + + 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'); + + runInAction(() => { + this.status = 'loading'; + this.isPreview = Boolean(options?.preview); + if (options?.preview) { + this.dismissedPreviewId = null; + } + }); + + 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.status = 'ready'; + }); + } catch { + runInAction(() => { + this.status = 'error'; + }); + } + } + + /** @internal test helper */ + dismissForTest(id: string): void { + 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 new file mode 100644 index 0000000000..560e588481 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx @@ -0,0 +1,49 @@ +import { Megaphone, RotateCcw } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { appState } from '@renderer/lib/stores/app-state'; +import { Button } from '@renderer/lib/ui/button'; +import { SettingRow } from './SettingRow'; + +export const AnnouncementDevControls = observer( + function AnnouncementDevControls(): React.JSX.Element | null { + if (!import.meta.env.DEV) return null; + + const store = appState.featureAnnouncements; + + return ( + + + + + } + /> + ); + } +); 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..a829499893 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/UpdateCard.tsx @@ -5,6 +5,7 @@ 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 { @@ -58,6 +59,8 @@ export const UpdateCard = observer(function UpdateCard(): React.JSX.Element { /> )} + + ); 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 (
+ ); }); 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..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,6 +31,9 @@ async function bootstrap() { wireExternalLinkRequests(); appState.update.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/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 new file mode 100644 index 0000000000..b9219a01b0 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/constants.ts @@ -0,0 +1,8 @@ +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_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 new file mode 100644 index 0000000000..6c76e125e6 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/feature-announcements.toml.test.ts @@ -0,0 +1,46 @@ +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 { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } 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 () => { + 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); + }); + + 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[]; + $defs?: { + cta?: { additionalProperties?: boolean; properties?: Record }; + }; + }; + + expect(schema.additionalProperties).toBe(false); + 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 new file mode 100644 index 0000000000..7725f73919 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts @@ -0,0 +1,54 @@ +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', + minAppVersion: '1.1.27', + cta: { + action: 'open-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 removed card-only fields', () => { + expect( + parseFeatureAnnouncementManifest({ + ...sampleManifest, + hero: 'automations', + features: [{ title: 'Run agents on a schedule', description: 'Launch agents on a cron.' }], + learnMoreUrl: 'https://docs.emdash.sh', + }) + ).toBeNull(); + }); + + it('rejects invalid CTA definitions', () => { + expect( + parseFeatureAnnouncementManifest({ + ...sampleManifest, + 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 new file mode 100644 index 0000000000..89f578b743 --- /dev/null +++ b/apps/emdash-desktop/src/shared/feature-announcements/schema.ts @@ -0,0 +1,49 @@ +import z from 'zod'; +import { FEATURE_ANNOUNCEMENT_CTA_ACTIONS } from './constants'; + +const featureAnnouncementCtaActionSchema = z.enum(FEATURE_ANNOUNCEMENT_CTA_ACTIONS); + +const featureAnnouncementCtaSchema = z + .object({ + action: featureAnnouncementCtaActionSchema.optional(), + url: z.url().optional(), + }) + .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), + changelogUrl: z.url(), + minAppVersion: z.string().min(1).optional(), + cta: featureAnnouncementCtaSchema.optional(), + }) + .strict(); + +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; +} + +/** 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/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'; 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"