Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
version: ${{ env.PNPM_VERSION }}

- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
apps/extension/scripts/release-readiness/policies/*.json
apps/extension/tests/mv3/scenarios.v1.json
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,17 @@ Tout le code dans `src/dev/` est derrière `import.meta.env.DEV` et n'est jamais
11. **Importer du Shell depuis le Core** — `core/` ne doit JAMAIS importer depuis `shell/`
12. **Utiliser `Date.now()` ou `new Date()` dans le Core** — Injecter via paramètre depuis le Shell
13. **Mettre de l'I/O dans le Core** — Pas de `fetch`, `indexedDB`, `chrome.*` dans `core/`

## Cursor Cloud specific instructions

Environnement : Node ≥22 et pnpm 10.x sont préinstallés. Le script de mise à jour lance `pnpm install`, donc les dépendances sont déjà présentes au démarrage d'une session.

Commandes standards (voir `package.json` racine et `README.md`) : `pnpm lint`, `pnpm test`, `pnpm build` — toutes passent en l'état (le lint ne renvoie que des warnings, 0 erreur).

Lancer le produit principal (extension Chrome) sans navigateur :

- `pnpm --filter @pulse/extension dev` (ou `pnpm dev` à la racine) sert le side panel sur le port **5176** (`strictPort`, cf. `apps/extension/vite.config.ts`).
- Ouvrir `http://localhost:5176/src/sidepanel/index.html` (la racine `/` renvoie 404, c'est normal). En dev, les APIs `chrome.*` sont stubées automatiquement et le feed est peuplé de missions mock — aucune extension chargée ni navigateur MV3 requis.
- `Ctrl+Shift+D` ouvre le Dev Panel (injection de missions, toggle états, logs bridge).

Services optionnels non disponibles par défaut : `pnpm dev:local` / `pnpm supabase:*` nécessitent **Docker + Supabase CLI**, qui ne sont PAS installés dans l'environnement cloud. Les apps `landing` (5173) et `dashboard` (5174) buildent et démarrent sans Supabase (auth/sync désactivés) ; seul le noyau extension est requis pour développer/tester le produit principal.
16 changes: 8 additions & 8 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,21 @@
"dependencies": {
"@pulse/domain": "workspace:*",
"@pulse/ui": "workspace:*",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.110.0",
"@supabase/ssr": "^0.12.3",
"@supabase/supabase-js": "^2.110.7",
"@vercel/microfrontends": "^2.3.6",
"svelte": "^5.56.4"
"svelte": "^5.56.6"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@pulse/tsconfig": "workspace:*",
"@sveltejs/adapter-vercel": "^6.3.2",
"@sveltejs/kit": "^2.69.1",
"@sveltejs/vite-plugin-svelte": "^5.1.1",
"@tailwindcss/vite": "^4.3.2",
"svelte-check": "^4.7.1",
"@sveltejs/kit": "^2.70.0",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/vite": "^4.3.3",
"svelte-check": "^4.7.3",
"typescript": "^5.7.0",
"vite": "^8.1.4",
"vite": "^8.1.5",
"vitest": "^3.2.6"
}
}
27 changes: 22 additions & 5 deletions apps/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,20 +1150,37 @@ async function persistPostCommitEffects(
return;
}

// Update badge with new mission count
// Unseen missions feed the notification filter. The icon badge must mirror
// that notifiable differential (high-score / smart-alert matches), not the
// raw unseen pool — otherwise a first scan of ~1000 missions badges "1000"
// while the Chrome notification correctly reports only ~2 new ones.
const seenIds = await getSeenIds();
const seenSet = new Set(seenIds);
const newMissions = missions.filter((m) => !seenSet.has(m.id));
const newCount = newMissions.length;

if (newCount > 0) {
await setNewMissionCount(newCount);
await chrome.action.setBadgeText({ text: String(newCount) });
const notification =
newMissions.length > 0
? await notifyHighScoreMissions(newMissions, settingsSnapshot)
: { shown: false, notifiedMissionIds: [], notifiableMissionIds: [] };

const badgeCount = notification.notifiableMissionIds.length;
if (badgeCount > 0) {
await setNewMissionCount(badgeCount);
await chrome.action.setBadgeText({ text: String(badgeCount) });
await chrome.action.setBadgeBackgroundColor({ color: '#58d9a9' });
await chrome.action.setBadgeTextColor({ color: '#ffffff' });
} else {
await clearNewMissionBadge();
}
<<<<<<< HEAD

// notifyHighScoreMissions persists its focus intent before showing Chrome's
// notification, so a fast click cannot race ahead of that write.
if (notification.shown && notification.notifiedMissionIds.length > 0) {
await saveSeenIds(markAsSeen(seenIds, notification.notifiedMissionIds));
}
=======
>>>>>>> origin/develop
} catch {
// Badge projection is non-critical after commit. High-score notifications
// are evaluated separately, after semantic enrichment, so fused semantic
Expand Down
74 changes: 49 additions & 25 deletions apps/extension/src/lib/shell/notifications/notify-missions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,24 @@ const loadLastNotificationTime = async (): Promise<number | null> => {

export interface NotificationResult {
shown: boolean;
/** Mission IDs included in a Chrome notification that was actually shown. */
notifiedMissionIds: string[];
/**
* Mission IDs that passed notification filters for this scan.
* Populated even when the Chrome notification was not shown (rate-limit or
* create failure). Empty when alerts are disabled/muted or nothing matches.
* The extension icon badge uses this so it matches the notification
* differential instead of counting every unseen mission from the scan.
*/
notifiableMissionIds: string[];
}

const emptyNotificationResult = (): NotificationResult => ({
shown: false,
notifiedMissionIds: [],
notifiableMissionIds: [],
});

/**
* Creates Chrome notifications for high-score missions.
*
Expand All @@ -86,52 +101,48 @@ export interface NotificationResult {
* - Clicking the notification opens the side panel
*
* @param missions - All missions from the scan
* @returns Whether a notification was shown, and which mission IDs were included
* @returns Whether a notification was shown, which mission IDs were included,
* and which IDs passed filters (for the extension badge)
*/
export const notifyHighScoreMissions = async (
missions: Mission[],
admittedSnapshot?: SettingsReleaseSnapshot
): Promise<NotificationResult> => {
if (missions.length === 0) {
return { shown: false, notifiedMissionIds: [] };
return emptyNotificationResult();
}

// Check if notifications are enabled
let settings: AppSettings;
try {
settings = (admittedSnapshot ?? (await readSettingsReleaseSnapshot())).settings;
} catch {
return { shown: false, notifiedMissionIds: [] };
return emptyNotificationResult();
}

if (!settings.notifications) {
return { shown: false, notifiedMissionIds: [] };
return emptyNotificationResult();
}

// Check rate limit
const lastTime = await loadLastNotificationTime();
const now = Date.now();

if (!canNotify(lastTime, now)) {
return { shown: false, notifiedMissionIds: [] };
}

// Filter missions above threshold that haven't been seen
// Filter missions above threshold that haven't been seen. Done before the
// rate-limit gate so the badge can still reflect the differential when a
// Chrome notification is skipped due to cooldown.
let seenIds: string[] = [];
try {
seenIds = await getSeenIds();
} catch {
// If we can't load seen IDs, proceed without filtering
}

const now = Date.now();
const connectedAlertPreferences = await getConnectedAlertPreferences();

if (connectedAlertPreferences && !connectedAlertPreferences.enabled) {
return { shown: false, notifiedMissionIds: [] };
return emptyNotificationResult();
}

if (connectedAlertPreferences && isMutedUntilActive(connectedAlertPreferences.mutedUntil, now)) {
return { shown: false, notifiedMissionIds: [] };
return emptyNotificationResult();
}

const notifiableMissions = connectedAlertPreferences
Expand All @@ -143,8 +154,20 @@ export const notifyHighScoreMissions = async (
})
: filterNotifiableMissions(missions, seenIds, settings.notificationScoreThreshold);

if (notifiableMissions.length === 0) {
return { shown: false, notifiedMissionIds: [] };
const notifiableMissionIds = notifiableMissions.map((mission) => mission.id);

if (notifiableMissionIds.length === 0) {
return emptyNotificationResult();
}

// Check rate limit after filter so callers can still badge the differential.
const lastTime = await loadLastNotificationTime();
if (!canNotify(lastTime, now)) {
return {
shown: false,
notifiedMissionIds: [],
notifiableMissionIds,
};
}

// Build notification content based on count
Expand Down Expand Up @@ -174,11 +197,7 @@ export const notifyHighScoreMissions = async (
// the most recent notification is what the user expects to land on. If the
// notification creation fails below, we roll the intent back so a stale
// intent doesn't hijack the next panel open.
const intent = createDeepLinkIntent(
notifiableMissions.map((mission) => mission.id),
'notification',
now
);
const intent = createDeepLinkIntent(notifiableMissionIds, 'notification', now);
if (intent) {
await setDeepLinkIntent(intent);
}
Expand All @@ -199,7 +218,7 @@ export const notifyHighScoreMissions = async (
id: buildAlertHistoryId(now, notifiableMissions),
triggeredAt: now,
missionCount,
missionIds: notifiableMissions.map((mission) => mission.id),
missionIds: notifiableMissionIds,
missionTitles: notifiableMissions.map((mission) => mission.title),
scoreThreshold:
connectedAlertPreferences?.scoreThreshold ?? settings.notificationScoreThreshold,
Expand All @@ -210,14 +229,19 @@ export const notifyHighScoreMissions = async (

return {
shown: true,
notifiedMissionIds: notifiableMissions.map((mission) => mission.id),
notifiedMissionIds: notifiableMissionIds,
notifiableMissionIds,
};
} catch (err) {
console.error('[MissionPulse] Failed to create notification:', err);
// Rollback the intent we wrote optimistically above so the next panel open
// doesn't land on missions the user was never actually notified about.
await clearDeepLinkIntent().catch(() => {});
return { shown: false, notifiedMissionIds: [] };
return {
shown: false,
notifiedMissionIds: [],
notifiableMissionIds,
};
}
};

Expand Down
6 changes: 5 additions & 1 deletion apps/extension/src/models/notification-deep-link.model.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,13 @@ at notify time. The legacy `showNewOnly` filter excludes seen missions, so
toggling it after a notification would hide the very missions we want to show.
The focus lens bypasses seen entirely (F2): focus is an explicit id allow-list
applied after the normal pipeline, so seen-marking becomes harmless to the
deep-link UX. We keep the seen-mark (it powers the badge/new-count correctly)
deep-link UX. We keep the seen-mark (it prevents re-alerting the same missions)
but it no longer dictates the focus surface.

The extension icon badge is driven by `notifiableMissionIds` from
`notifyHighScoreMissions` (same score/smart-alert filter as the Chrome
notification), not by the raw unseen-mission count from the scan.

---

## 4. Zero-LLM invariant
Expand Down
76 changes: 71 additions & 5 deletions apps/extension/tests/unit/background/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ describe('background auto-scan notifications', () => {
notifyHighScoreMissions.mockResolvedValue({
shown: true,
notifiedMissionIds: ['mission-1'],
notifiableMissionIds: ['mission-1'],
});
getMissions.mockResolvedValue([makeMission()]);
saveMissions.mockResolvedValue(undefined);
Expand Down Expand Up @@ -913,8 +914,9 @@ describe('background auto-scan notifications', () => {
releaseSnapshot
);
expect(saveSeenIds).toHaveBeenCalledWith(['already-seen', 'mission-1']);
expect(setNewMissionCount).toHaveBeenCalledWith(2);
expect(setBadgeText).toHaveBeenCalledWith({ text: '2' });
// Badge mirrors the notifiable differential (mission-1), not all unseen (2).
expect(setNewMissionCount).toHaveBeenCalledWith(1);
expect(setBadgeText).toHaveBeenCalledWith({ text: '1' });
});
const feedProjectionMessages = vi
.mocked(chrome.runtime.sendMessage)
Expand All @@ -932,6 +934,54 @@ describe('background auto-scan notifications', () => {
]);
});

it('badges only notifiable missions even when the unseen pool is large', async () => {
const manyUnseen = Array.from({ length: 1000 }, (_, index) =>
makeMission({ id: `bulk-${index}`, score: 40 })
);
const missions = [
...manyUnseen,
makeMission({ id: 'hot-1', score: 95 }),
makeMission({ id: 'hot-2', score: 91 }),
];
runScan.mockImplementationOnce(
successfulScanImplementation({
missions,
sourceMissions: missions,
duplicateRelations: [],
errors: [],
})
);
notifyHighScoreMissions.mockResolvedValueOnce({
shown: true,
notifiedMissionIds: ['hot-1', 'hot-2'],
notifiableMissionIds: ['hot-1', 'hot-2'],
});

await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800002 });

await vi.waitFor(() => {
expect(setNewMissionCount).toHaveBeenCalledWith(2);
expect(setBadgeText).toHaveBeenCalledWith({ text: '2' });
});
expect(setBadgeText).not.toHaveBeenCalledWith({ text: '1002' });
});

it('still badges notifiable missions when Chrome notification is rate-limited', async () => {
notifyHighScoreMissions.mockResolvedValueOnce({
shown: false,
notifiedMissionIds: [],
notifiableMissionIds: ['mission-1'],
});

await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800003 });

await vi.waitFor(() => {
expect(setNewMissionCount).toHaveBeenCalledWith(1);
expect(setBadgeText).toHaveBeenCalledWith({ text: '1' });
});
expect(saveSeenIds).not.toHaveBeenCalled();
});

it('clears badge and new mission count when all fetched missions are already seen', async () => {
const missions = [makeMission({ id: 'already-seen', score: 92 })];
runScan.mockImplementationOnce(
Expand All @@ -942,9 +992,8 @@ describe('background auto-scan notifications', () => {
errors: [],
})
);
notifyHighScoreMissions.mockResolvedValueOnce({ shown: false, notifiedMissionIds: [] });

await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800002 });
await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800004 });

await vi.waitFor(() => {
expect(setNewMissionCount).toHaveBeenCalledWith(0);
Expand All @@ -964,7 +1013,7 @@ describe('background auto-scan notifications', () => {
})
);

await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800003 });
await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800005 });

await vi.waitFor(() => {
expect(setNewMissionCount).toHaveBeenCalledWith(0);
Expand All @@ -973,6 +1022,23 @@ describe('background auto-scan notifications', () => {
expect(notifyHighScoreMissions).not.toHaveBeenCalled();
});

it('clears badge when no unseen mission passes notification filters', async () => {
notifyHighScoreMissions.mockResolvedValueOnce({
shown: false,
notifiedMissionIds: [],
notifiableMissionIds: [],
});

await alarmListener?.({ name: 'auto-scan', scheduledTime: 1779436800006 });

await vi.waitFor(() => {
expect(notifyHighScoreMissions).toHaveBeenCalled();
expect(setNewMissionCount).toHaveBeenCalledWith(0);
expect(setBadgeText).toHaveBeenCalledWith({ text: '' });
});
expect(saveSeenIds).not.toHaveBeenCalled();
});

it('acknowledges start and cancel non-terminally, then broadcasts cancelled once after quiescence', async () => {
expect(messageListener).toBeTypeOf('function');
let activeSignal: AbortSignal | undefined;
Expand Down
Loading
Loading