diff --git a/apps/extension/src/lib/shell/connectors/meta.ts b/apps/extension/src/lib/shell/connectors/meta.ts index 30aa79e9..7dbf8799 100644 --- a/apps/extension/src/lib/shell/connectors/meta.ts +++ b/apps/extension/src/lib/shell/connectors/meta.ts @@ -32,7 +32,6 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=free-work.com&sz=32', url: 'https://www.free-work.com', hostPermissions: ['https://www.free-work.com/*'], - // Pilote Form Assistant Phase 1 (Gemini Nano local uniquement). formAssist: true, }, { @@ -41,6 +40,7 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=lehibou.com&sz=32', url: 'https://www.lehibou.com', hostPermissions: ['https://*.lehibou.com/*'], + formAssist: true, }, { id: 'hiway', @@ -50,6 +50,7 @@ const CATALOG: readonly ConnectorMeta[] = [ // Hiway fetches missions from a Supabase REST endpoint; that host is // Hiway-owned infra and must be dropped when Hiway is excluded. hostPermissions: ['https://hiway-missions.fr/*', 'https://jhgjtlkfewuiiofxfrvh.supabase.co/*'], + formAssist: true, }, { id: 'collective', @@ -57,6 +58,7 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=collective.work&sz=32', url: 'https://app.collective.work/', hostPermissions: ['https://*.collective.work/*'], + formAssist: true, }, { id: 'cherry-pick', @@ -64,6 +66,7 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=cherry-pick.io&sz=32', url: 'https://www.cherry-pick.io', hostPermissions: ['https://app.cherry-pick.io/*'], + formAssist: true, }, { id: 'malt', @@ -71,6 +74,7 @@ const CATALOG: readonly ConnectorMeta[] = [ icon: 'https://www.google.com/s2/favicons?domain=malt.fr&sz=32', url: 'https://www.malt.fr', hostPermissions: ['https://*.malt.fr/*', 'https://*.malt.io/*'], + formAssist: true, }, ] as const; diff --git a/apps/extension/src/lib/state/settings-page.svelte.ts b/apps/extension/src/lib/state/settings-page.svelte.ts index fb832949..7ab7f1df 100644 --- a/apps/extension/src/lib/state/settings-page.svelte.ts +++ b/apps/extension/src/lib/state/settings-page.svelte.ts @@ -81,6 +81,7 @@ export class SettingsPageController { private readonly shippedConnectorCatalog: readonly ConnectorMeta[]; private readonly resetAvailability: LocalDataResetRuntimeAvailability; private readonly unsubscribeProfileMessages = this.subscribeProfileMessages(); + private readonly unsubscribeFormAssistMessages = this.subscribeFormAssistMessages(); private readonly unsubscribeSettingsSnapshots: () => void; private readonly profileActor = createProfileStore({ @@ -127,6 +128,14 @@ export class SettingsPageController { connectedPendingDownloads = $state(0); connectedSyncError = $state(null); + /** + * Form Assistant activation (Machine D — src/models/form-assistant.model.md). + * Miroir de l'état persisté dans le SW (chrome.storage.local). Le toggle ne + * écrit JAMAIS directement en storage : il émet FORM_ASSIST_ENABLE via bridge. + */ + formAssistEnabled = $state(false); + formAssistStatus = $state<'loading' | 'ready' | 'error'>('loading'); + showResetConfirm = $state(false); resetError = $state(null); @@ -173,8 +182,26 @@ export class SettingsPageController { } } + /** + * Racolement Machine D : le SW diffuse FORM_ASSIST_ENABLED après toute + * mutation persistée. Le panel est un miroir, jamais la source primaire. + */ + private subscribeFormAssistMessages(): () => void { + try { + return subscribeMessages((message) => { + if (message.type === 'FORM_ASSIST_ENABLED') { + this.formAssistEnabled = Boolean(message.payload.enabled); + this.formAssistStatus = 'ready'; + } + }); + } catch { + return () => {}; + } + } + destroy(): void { this.unsubscribeProfileMessages(); + this.unsubscribeFormAssistMessages(); this.unsubscribeSettingsSnapshots(); } @@ -201,6 +228,7 @@ export class SettingsPageController { this.loadSettings(), this.loadConnectedAccount(), this.loadScanHistory(), + this.loadFormAssist(), ]); } @@ -237,6 +265,51 @@ export class SettingsPageController { } } + /** + * Lit l'état persisté du Form Assistant auprès du SW (Machine D — INIT). + * Échec (SW injoignable) → état `error` mais la page reste utilisable. + */ + async loadFormAssist(): Promise { + this.formAssistStatus = 'loading'; + try { + const result = (await sendMessage({ type: 'FORM_ASSIST_STATUS' })) as + { type: 'FORM_ASSIST_STATUS_RESULT'; payload: { enabled: boolean } } | undefined; + this.formAssistEnabled = Boolean(result?.payload.enabled); + this.formAssistStatus = 'ready'; + } catch { + this.formAssistStatus = 'error'; + } + } + + /** + * Bascule l'activation du Form Assistant. Le SW persiste et diffuse + * FORM_ASSIST_ENABLED (racolement). L'UI reste optimiste : on met à jour + * immédiatement pour la réactivité, et le message SW confirme/rétablit. + */ + async toggleFormAssist(): Promise { + if (this.formAssistStatus === 'loading') { + return; + } + const next = !this.formAssistEnabled; + const previous = this.formAssistEnabled; + this.formAssistEnabled = next; + this.formAssistStatus = 'loading'; + try { + const result = (await sendMessage({ + type: 'FORM_ASSIST_ENABLE', + payload: { enabled: next }, + })) as { type: 'FORM_ASSIST_ENABLED'; payload: { enabled: boolean } } | undefined; + // La réponse du SW est la source de vérité (elle peut différer de + // l'optimisme en cas d'erreur persistée côté SW). + this.formAssistEnabled = Boolean(result?.payload.enabled); + this.formAssistStatus = 'ready'; + } catch { + this.formAssistEnabled = previous; + this.formAssistStatus = 'error'; + await showToast("Impossible d'activer l'assistant de candidature", 'error'); + } + } + async loadSettings(): Promise { try { const settings = await getSettings(); diff --git a/apps/extension/src/models/form-assistant.model.md b/apps/extension/src/models/form-assistant.model.md index 91ac1758..563b724f 100644 --- a/apps/extension/src/models/form-assistant.model.md +++ b/apps/extension/src/models/form-assistant.model.md @@ -101,6 +101,37 @@ interface FieldDescriptor { // JAMAIS : id/name du champ, valeurs d'autres champs, URL, HTML voisin. ``` +## Machine D — Activation côté panel (side panel ↔ SW) + +Le toggle d'activation vit dans la page **Settings**. Il reflète l'état persisté +dans le SW (`formAssist.enabled`, `chrome.storage.local`) et émet +`FORM_ASSIST_ENABLE` sur action utilisateur explicite. **Aucun LLM dans cette +machine** : la décision est purement un booléen user-owned persisté côté SW. + +```text +unknown ──INIT──► loading +loading ──STATUS_RESULT(enabled)──► on | off +loading ──SW_UNREACHABLE──► error +off ──TOGGLE(on)──► loading (optimiste, garde anti-double-clic) +on ──TOGGLE(off)──► loading +on|off ──ENABLED(enabled)──► on | off (raccolement via diffusion SW) +error ──RETRY──► loading +``` + +- `loading` après `TOGGLE` est **optimiste** : l'UI désactive le toggle + (`disabled`) jusqu'au `FORM_ASSIST_ENABLED` de confirmation pour interdire + tout double-envoi. L'échec (`SW_UNREACHABLE`) ramène à l'état précédent **et** + affiche un toast typé (jamais silencieux). +- Le `FORM_ASSIST_ENABLED` diffusé par le SW (qui notifie aussi le content +- script) racolette le toggle : le panel est une **source de vérité miroir**, pas + primaire. La source primaire reste le `chrome.storage.local` du SW. +- Le moteur distant (Eve) reste **hors périmètre du toggle** : ce dernier + n'expose que le booléen `enabled`. La préférence `engine` et le consentement + (Machine C) sont gérés séparément. +- Invariant : le toggle **n'écrit jamais** dans `chrome.storage` depuis le + panel — tout passe par le bridge typé vers le SW (règle « le panel n'appelle + jamais IndexedDB / chrome.storage directement »). + ## Machine A — Widget (content script, par champ focalisé) ```text @@ -171,8 +202,12 @@ denied ─PROMPT───► prompting ## Messages bridge (nouveaux) -- `FORM_ASSIST_ENABLE` (panel → SW) — `{ enabled, enginePref, perSite? }`. -- `FORM_ASSIST_STATUS` (SW → panel/content) — `{ armed, engineAvailability }`. +- `FORM_ASSIST_ENABLE` (panel → SW) — `{ enabled }` (booléen user-owned ; + `enginePref`/`perSite` restent HORS périmètre du toggle v1, gérés par défaut). + Le SW persiste, puis **diffuse** `FORM_ASSIST_ENABLED` vers le panel (racolement) + ET vers le content script (arm/disarm). +- `FORM_ASSIST_STATUS` (panel/content → SW) — lecture de l'état persisté. +- `FORM_ASSIST_STATUS_RESULT` (SW → panel/content) — `{ enabled, engine }`. - `FORM_ASSIST_REQUEST` (content → SW) — `{ requestId, field: FieldDescriptor }` (le SW relit le profil canonique lui-même ; **pas de profil côté contenu**). - `FORM_ASSIST_PROPOSAL` (SW → content) — `{ requestId, proposal: { text, engine } }`. @@ -206,6 +241,11 @@ rollout (même exigence que le Copilot dossier). qui déclarent `formAssist: true` dans le catalogue (`meta.ts`). Un connecteur exclu au build (cf. `connector-build-config.model.md`) exclut aussi l'assistant — cohérence de moindre privilège. +- **Tous les connecteurs du catalogue** déclarent `formAssist: true` par défaut + (Free-Work, LeHibou, Hiway, Collective, Cherry Pick, Malt) — conformément à + la portée produit ci-dessus. Le content script étant **générique** + (détection de champ conservatrice, aucun parsing spécifique à une plateforme), + l'activation ne dépend que du flag catalogue, pas d'une logique par site. - Aucune nouvelle permission large : on réutilise `host_permissions` + `scripting` + `storage`. - En **dev**, le content script se charge mais les `chrome.*` sont stubés diff --git a/apps/extension/src/ui/pages/SettingsPage.svelte b/apps/extension/src/ui/pages/SettingsPage.svelte index f53aa027..4545d8ad 100644 --- a/apps/extension/src/ui/pages/SettingsPage.svelte +++ b/apps/extension/src/ui/pages/SettingsPage.svelte @@ -912,6 +912,44 @@ + + +
+
+
+ +
+
+

Assistant de candidature

+

+ Au focus sur un champ d'un formulaire de candidature, propose une valeur issue de + votre profil. Vous restez seul à valider l'insertion (rien n'est jamais rempli + automatiquement). +

+
+ settings.toggleFormAssist()} + /> +
+
+
+ +
+

Périmètre et confidentialité

+

+ Actif uniquement sur les plateformes connecteurs compatibles (Free-Work pour la + phase pilote). La génération utilise Gemini Nano en local ; aucune donnée de page + n'est envoyée à un serveur. +

+
+
+
+
diff --git a/apps/extension/tests/unit/state/settings-page.test.ts b/apps/extension/tests/unit/state/settings-page.test.ts index 7a51e32f..3418fba6 100644 --- a/apps/extension/tests/unit/state/settings-page.test.ts +++ b/apps/extension/tests/unit/state/settings-page.test.ts @@ -387,6 +387,141 @@ describe('SettingsPageController — confirmed settings projection', () => { }); }); +describe('SettingsPageController — Form Assistant toggle (Machine D)', () => { + beforeEach(() => { + bridgeMock.sendMessage.mockReset(); + toastMock.showToast.mockClear(); + }); + + it('reads the persisted Form Assistant status from the SW on load', async () => { + bridgeMock.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FORM_ASSIST_STATUS') { + return Promise.resolve({ + type: 'FORM_ASSIST_STATUS_RESULT', + payload: { enabled: true, engine: 'local' as const }, + }); + } + return Promise.resolve({ type: 'SETTINGS_RESULT', payload: null }); + }); + + const controller = new SettingsPageController(); + await controller.loadFormAssist(); + + expect(controller.formAssistEnabled).toBe(true); + expect(controller.formAssistStatus).toBe('ready'); + + controller.destroy(); + }); + + it('falls back to error state when the SW is unreachable on load', async () => { + bridgeMock.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FORM_ASSIST_STATUS') { + return Promise.reject(new Error('SW injoignable')); + } + return Promise.resolve({ type: 'SETTINGS_RESULT', payload: null }); + }); + + const controller = new SettingsPageController(); + await controller.loadFormAssist(); + + expect(controller.formAssistStatus).toBe('error'); + controller.destroy(); + }); + + it('emits FORM_ASSIST_ENABLE and projects the SW-confirmed value on toggle', async () => { + bridgeMock.sendMessage.mockImplementation((message: { type: string; payload?: unknown }) => { + if (message.type === 'FORM_ASSIST_ENABLE') { + const enabled = (message.payload as { enabled: boolean }).enabled; + return Promise.resolve({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled, engine: 'local' as const }, + }); + } + return Promise.resolve({ type: 'SETTINGS_RESULT', payload: null }); + }); + + const controller = new SettingsPageController(); + await controller.loadFormAssist(); + controller.formAssistEnabled = false; + + await controller.toggleFormAssist(); + + expect(controller.formAssistEnabled).toBe(true); + expect(controller.formAssistStatus).toBe('ready'); + expect(bridgeMock.sendMessage).toHaveBeenCalledWith({ + type: 'FORM_ASSIST_ENABLE', + payload: { enabled: true }, + }); + + controller.destroy(); + }); + + it('reverts to the previous value and toasts on SW failure', async () => { + bridgeMock.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FORM_ASSIST_ENABLE') { + return Promise.reject(new Error('persist failed')); + } + if (message.type === 'FORM_ASSIST_STATUS') { + return Promise.resolve({ + type: 'FORM_ASSIST_STATUS_RESULT', + payload: { enabled: false, engine: 'local' as const }, + }); + } + return Promise.resolve({ type: 'SETTINGS_RESULT', payload: null }); + }); + + const controller = new SettingsPageController(); + await controller.loadFormAssist(); + + await controller.toggleFormAssist(); + + expect(controller.formAssistEnabled).toBe(false); + expect(controller.formAssistStatus).toBe('error'); + expect(toastMock.showToast).toHaveBeenCalledWith(expect.any(String), 'error'); + + controller.destroy(); + }); + + it('racolements via the FORM_ASSIST_ENABLED broadcast', async () => { + const controller = new SettingsPageController(); + controller.formAssistEnabled = false; + + for (const listener of [...bridgeMock.listeners]) { + listener({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: true, engine: 'local' as const }, + }); + } + + expect(controller.formAssistEnabled).toBe(true); + expect(controller.formAssistStatus).toBe('ready'); + controller.destroy(); + }); + + it('ignores a toggle while a request is in flight (double-clic guard)', async () => { + bridgeMock.sendMessage.mockImplementation((message: { type: string }) => { + if (message.type === 'FORM_ASSIST_ENABLE') { + return Promise.resolve({ + type: 'FORM_ASSIST_ENABLED', + payload: { enabled: true, engine: 'local' as const }, + }); + } + return Promise.resolve({ type: 'SETTINGS_RESULT', payload: null }); + }); + + const controller = new SettingsPageController(); + // Force an in-flight state without awaiting the previous toggle. + controller.formAssistStatus = 'loading'; + + await controller.toggleFormAssist(); + + expect(bridgeMock.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'FORM_ASSIST_ENABLE' }) + ); + controller.destroy(); + }); +}); + describe('SettingsPageController — shipped connector catalogue', () => { beforeEach(() => { bridgeMock.sendMessage.mockReset();