feat: feature announcement toast#2642
Conversation
Greptile SummaryThis PR adds a remote-configured in-app feature announcement system that fetches a TOML manifest from the main GitHub branch at startup and shows a toast if the announcement is enabled, the app version meets the minimum, and the user hasn't dismissed it before.
Confidence Score: 5/5Safe to merge; the announcement feature is well-isolated, schema-validated on both the Zod and JSON Schema sides, and has good test coverage across store, state, schema, and media layers. The two findings are both style/hardening concerns: an observable mutation that should use apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts — the two suggestions in
|
| Filename | Overview |
|---|---|
| apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.ts | Core MobX store orchestrating manifest fetch, dismissal state, and presentation logic; clearDismissal() mutates the dismissedIds observable directly without runInAction, inconsistent with every other async mutation in the file. |
| apps/emdash-desktop/src/main/core/feature-announcements/service.ts | Fetches and parses the remote TOML manifest with a 15-minute cache; correctly handles network failures, parse errors, and semver version gating. |
| apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-state.ts | Pure async helpers for reading/writing dismissal state; well-tested, injectable client pattern makes unit testing straightforward. |
| apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-toast.tsx | Renders the custom Sonner toast card; routes CTA actions (external URL or in-app navigation) and exposes dismiss/learn-more handlers correctly. |
| apps/emdash-desktop/src/shared/feature-announcements/schema.ts | Zod schema with strict mode and a refine that correctly rejects CTAs with both or neither of action/url; exports typed parse helpers for enabled-only vs. raw (preview) manifests. |
| apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-launcher.tsx | Observer component that fires the toast after an 800 ms delay once the workspace is visible and all preconditions are met; correctly cleans up the timer on unmount or condition change. |
| apps/emdash-desktop/src/renderer/features/settings/components/AnnouncementDevControls.tsx | DEV-only UI for previewing and resetting the announcement; gated by import.meta.env.DEV at render time so it never appears in production builds. |
| apps/emdash-desktop/src/shared/feature-announcements/schema.test.ts | Covers enabled/disabled manifests, legacy field rejection, and dual-CTA rejection; good coverage of the refine edge cases. |
| apps/emdash-desktop/src/renderer/features/feature-announcements/feature-announcement-store.test.ts | Tests dismissal logic, preview-mode persistence guard, and the fresh-install offline scenario; comprehensive coverage of the store's public surface. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant R as Renderer (main.tsx)
participant S as FeatureAnnouncementStore
participant M as Main Process (service.ts)
participant GH as GitHub Raw Content
participant AS as AppSettings Store
R->>S: "start({ isFreshInstall })"
par Parallel fetch
S->>M: rpc.featureAnnouncements.getCurrent()
M->>GH: fetch feature-announcements.toml
GH-->>M: TOML content
M-->>S: "manifest | null"
and Load dismissal state
S->>AS: rpc.appSettings.get('announcements')
AS-->>S: "{ initialized, dismissedIds }"
end
alt "isFreshInstall && !initialized"
S->>AS: "write { initialized: true, dismissedIds: [manifest?.id] }"
S->>AS: reload dismissal state
end
Note over S: status = 'ready', manifest set
R->>S: FeatureAnnouncementLauncher observes store
alt "shouldPresent (manifest exists && id not dismissed)"
Note over R: setTimeout 800ms
R->>S: markPresented()
S->>AS: markAnnouncementDismissed(id)
R->>R: showFeatureAnnouncementToast(manifest)
end
alt User clicks CTA action
R->>R: appState.navigation.navigate('automations')
else User clicks Learn More
R->>R: confirmOpenExternalLink(learnMoreUrl)
else User dismisses
R->>R: toast.dismiss()
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant R as Renderer (main.tsx)
participant S as FeatureAnnouncementStore
participant M as Main Process (service.ts)
participant GH as GitHub Raw Content
participant AS as AppSettings Store
R->>S: "start({ isFreshInstall })"
par Parallel fetch
S->>M: rpc.featureAnnouncements.getCurrent()
M->>GH: fetch feature-announcements.toml
GH-->>M: TOML content
M-->>S: "manifest | null"
and Load dismissal state
S->>AS: rpc.appSettings.get('announcements')
AS-->>S: "{ initialized, dismissedIds }"
end
alt "isFreshInstall && !initialized"
S->>AS: "write { initialized: true, dismissedIds: [manifest?.id] }"
S->>AS: reload dismissal state
end
Note over S: status = 'ready', manifest set
R->>S: FeatureAnnouncementLauncher observes store
alt "shouldPresent (manifest exists && id not dismissed)"
Note over R: setTimeout 800ms
R->>S: markPresented()
S->>AS: markAnnouncementDismissed(id)
R->>R: showFeatureAnnouncementToast(manifest)
end
alt User clicks CTA action
R->>R: appState.navigation.navigate('automations')
else User clicks Learn More
R->>R: confirmOpenExternalLink(learnMoreUrl)
else User dismisses
R->>R: toast.dismiss()
end
Reviews (4): Last reviewed commit: "test(announcements): type schema require..." | Re-trigger Greptile
Description
adds remote-configured in-app announcement
add dev controls to preview/show
app fetches feature-announcement.toml from main on startup
if
enabled=true, the app version matchesminAppVersion, and the announcement id was not dismissed before, it shows a toastdismissed announcement ids are stored
Screenshot/Recording (if applicable)
https://streamable.com/qlkocf
Checklist
messages and, when possible, the PR title