diff --git a/CLAUDE.md b/CLAUDE.md index 8c33fc2..21a1939 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,6 +389,33 @@ placing anything. Specifically: A running log of durable decisions and feedback captured into the project config. Newest first. +- 2026-06-26 — **Fix #2 (onboarding STILL didn't save — auto-save was scoped to COMPLETED sections only; SPEC 43 + re-amended; on `fix/onboarding-autosave-all-sections`).** The v0.11.1 fix shipped but the user came back + (rightly angry): "YOURE OBVIOUSLY NOT PROPERLY TESTING — onboarding questions arent being saved when selected + and when changing the private status its not saving automatically." **My failure:** I "verified" with an E2E I + wrote to pass (it tested a COMPLETED section) instead of reproducing the user's REAL scenario (first-time + onboarding). **This time I reproduced against the running app FIRST:** wrote a decrypt-level E2E driving a + first-time section → it FAILED on v0.11.1 (`occupation` stayed `null` after filling + changing sharing, no + Continue) — proving the bug, before touching the fix. **Root cause:** the auto-save effect was gated + `if (!complete || locked) return` — so it only fired on already-COMPLETED sections; while filling out + onboarding (every section `notStarted`/`inProgress`), nothing auto-saved. **Fix:** auto-save now fires for + **every** section, persisting a **DRAFT** — `submitSectionForm` gained a `markComplete` param (default `true`); + `autoSaveForm` passes **`complete: false`** so a draft persists answers + `answerSharing` but only nudges + `notStarted`→`inProgress`, NEVER completing the section (only the explicit Continue/Done does, so no premature + portrait). Added a **flush-on-unmount** (a `flushRef` → latest closure) so a quick Back/section-switch inside the + ~600ms debounce doesn't drop the last edit, and **cancel-on-explicit-submit** (`cancelAutoSave()` in the + Continue/Done/Skip handlers) so a debounced draft can't race in AFTER the completing submit and revert it. + Gate green: typecheck (all), lint, **939 core + 855 desktop** unit (the stale "first-time does NOT auto-save" + RTL INVERTED to assert it DOES, as `complete:false`; +a core `submitSectionForm` draft test: + persists-without-completing then an explicit submit completes), **E2E** (the new first-time decrypt test — fill + - change sharing with NO Continue → both the answer AND the scope persist to the vault, section not complete; + the existing edit-a-completed-section test still green). **Lessons: (1) NEVER claim verified from a test you + wrote to pass — reproduce the user's ACTUAL path (first-time vs editing) against the running app, watch it FAIL, + then fix. (2) An auto-save gated on a state the user isn't in yet (`complete`) is no auto-save — onboarding is + spent in not-complete sections, so that's exactly where save-on-select had to work. (3) A draft save (a + `markComplete:false` flag) is how you persist-as-you-go without the side effects of "submit" (completion + + portrait).** + - 2026-06-26 — **Fix (onboarding sharing didn't save — one-tap + auto-save; SPEC 43 amended; on `fix/onboarding-share-autosave`, PR pending).** User: "in intimacy & sexuality I click _share with partner all_ and Save, and it doesn't save — and let's save right away when clicked, same with all answers." **Diagnosed diff --git a/apps/desktop/e2e/launch.spec.ts b/apps/desktop/e2e/launch.spec.ts index 9759a04..5a6eec8 100644 --- a/apps/desktop/e2e/launch.spec.ts +++ b/apps/desktop/e2e/launch.spec.ts @@ -6761,6 +6761,96 @@ test('onboarding per-question sharing: scope a section to Partner → fact reach } }); +test('onboarding: a FIRST-TIME section auto-saves answers + sharing on select, before any Continue (the real reported bug)', async () => { + const { userData, vault } = await seedReadyVault({ 'ai.enabled': true }); + await createNodeSecretStore(userData, passthrough).set('anthropic.apiKey', 'sk-ant-e2e'); + const fs = createNodeFileSystem(vault); + const key = await loadMasterKey(createNodeSecretStore(userData, passthrough)); + if (!key) throw new Error('autosave e2e: master key missing'); + // A partner so the sharing picker offers a type, + an intake with `basics` NOT started (first-time). + { + const now = new Date().toISOString(); + await savePerson(fs, key, { + id: 'partner-b', + schemaVersion: 1, + displayName: 'Bee', + isSubject: true, + tags: [], + createdAt: now, + updatedAt: now, + }); + await saveRelationship(fs, key, { + id: 'rel-b', + schemaVersion: 1, + fromPersonId: 'owner-1', + toPersonId: 'partner-b', + type: 'partner', + createdAt: now, + updatedAt: now, + }); + await writeEncryptedJson( + fs, + 'people/owner-1/intake/session.enc', + { + id: 'intake-autosave', + schemaVersion: 1, + personId: 'owner-1', + status: 'inProgress', + sections: [ + { id: 'basics', status: 'notStarted', restricted: false, messages: [], answers: {} }, + { id: 'life-now', status: 'skipped', restricted: false, messages: [], answers: {} }, + ], + startedAt: 'now', + updatedAt: 'now', + }, + key, + ); + } + const app = await launch(userData); + try { + const w = await app.firstWindow(); + await w.getByRole('link', { name: 'Home' }).click(); + await w.getByRole('button', { name: /Start onboarding|Continue onboarding/ }).click(); + await expect(w.getByRole('heading', { name: 'The basics' })).toBeVisible(); + + // Fill an answer — and DO NOT click Continue/Done. It must auto-save as a draft on its own. + await w.getByRole('textbox', { name: 'What do you do for work?' }).fill('nurse'); + // Then change the section sharing to Partner ONLY (clear to Private, then add Partner) — also no Save click. + await w.getByRole('button', { name: /this whole section/i }).click(); + await w.getByRole('button', { name: 'Private (only me)' }).click(); + await w.getByRole('checkbox', { name: 'Partner' }).click(); + await w.keyboard.press('Escape'); + + // The first-time section auto-saves the answer (as a draft) the moment it's typed — no Continue click. + await expect + .poll( + async () => + (await getIntakeSession(fs, key, 'owner-1'))?.sections.find((s) => s.id === 'basics') + ?.answers?.occupation ?? null, + { timeout: 4000 }, + ) + .toBe('nurse'); + // …and the SHARING change auto-saves too (the reported "private status isn't saving" bug). + await expect + .poll( + async () => + (await getIntakeSession(fs, key, 'owner-1'))?.sections.find((s) => s.id === 'basics') + ?.answerSharing?.occupation ?? [], + { timeout: 4000 }, + ) + .toEqual(['partner']); + // A draft auto-save must NOT prematurely COMPLETE a section the person is still filling out. + const basics = (await getIntakeSession(fs, key, 'owner-1'))?.sections.find( + (s) => s.id === 'basics', + ); + expect(basics?.status).not.toBe('complete'); + } finally { + await app.close(); + await rm(userData, { recursive: true, force: true }); + await rm(vault, { recursive: true, force: true }); + } +}); + test('memory: an existing (pre-spec) portrait shares by default — Sharing reflects it, cards are clean (share-by-default backfill)', async () => { // Reproduces the real bug: a portrait synthesized BEFORE per-question sharing existed (answers present, // NO `answerSharing`, facts with NO `shareableTypes`) showed everything as "Private". The backfill resolves diff --git a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx index 2eba3ad..a902611 100644 --- a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx +++ b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.test.tsx @@ -479,7 +479,7 @@ describe('IntakeFormPanel — per-question sharing (43)', () => { ); }); - it('does NOT auto-save a first-time (incomplete) section — that still rides the explicit Continue', async () => { + it('auto-saves a FIRST-TIME section as a DRAFT (complete:false) so a change persists without Continue', async () => { const intakeSubmitForm = vi.fn(() => Promise.resolve({ session: {} as never, @@ -499,9 +499,15 @@ describe('IntakeFormPanel — per-question sharing (43)', () => { ); fireEvent.click(screen.getByRole('button', { name: /this whole section/i })); fireEvent.click(screen.getByRole('checkbox', { name: 'Partner' })); - // Give the debounce window time to pass — a first-time section must NOT auto-submit. - await new Promise((r) => setTimeout(r, 800)); - expect(intakeSubmitForm).not.toHaveBeenCalled(); + // A first-time section auto-saves the change too — but as a DRAFT (complete:false), so it never + // prematurely completes the section the person is still filling out. + await waitFor( + () => + expect(intakeSubmitForm).toHaveBeenCalledWith( + expect.objectContaining({ sectionId: 'intimacy', complete: false }), + ), + { timeout: 2000 }, + ); }); it('offers the one-tap "refresh your portrait" once an edit makes the portrait stale', () => { diff --git a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx index b8c1361..a80a242 100644 --- a/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx +++ b/apps/desktop/src/renderer/src/app/routes/onboarding/IntakeFormPanel.tsx @@ -194,28 +194,12 @@ export function IntakeFormPanel({ const locked = meta.adult && !adultAcknowledged; const complete = section?.status === 'complete'; - // Auto-save (2026-06-26): on a COMPLETED section being edited, persist answer + sharing changes the moment - // they happen (debounced), so a "share with partner" pick or an answer edit saves right away — no separate - // Save click. A first-time section keeps the explicit Continue (which is what marks it complete); auto-save - // never completes a section the person is still filling out. `firstRun` skips the initial seed render. - const firstRun = useRef(true); - useEffect(() => { - if (firstRun.current) { - firstRun.current = false; - return; - } - if (!complete || locked) return; - const t = setTimeout(() => void autoSaveForm(meta.id, toSubmit(), scopes), 600); - return () => clearTimeout(t); - // Re-run on any answer or sharing change; `complete`/`locked` gate it, the rest are stable. - }, [answers, scopes, complete, locked]); - // A matrix answer is a row→point record (Record) — keep it; every other intake answer is a // scalar/array. Any OTHER non-array object isn't a valid intake answer, so drop it defensively to match the // bridge contract (IntakeAnswerValueSchema). - const toSubmit = (): Record => { + const toSubmitFrom = (src: AnswerMap): Record => { const out: Record = {}; - for (const [qid, value] of Object.entries(answers)) { + for (const [qid, value] of Object.entries(src)) { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { if (Object.values(value).every((v) => typeof v === 'number')) { out[qid] = value as IntakeAnswerValue; @@ -226,6 +210,52 @@ export function IntakeFormPanel({ } return out; }; + const toSubmit = (): Record => toSubmitFrom(answers); + + // Auto-save (2026-06-26): persist every answer + sharing change the moment it's made (debounced), as a DRAFT + // (`autoSaveForm` → `complete: false`) — so picking a scope or answering a question saves right away with no + // Save click, whether the section is being filled for the FIRST time or edited later. A draft never completes + // the section (only the explicit Continue/Done does). The latest payload is mirrored to a ref so the unmount + // flush (Back / switching section within the debounce window) saves the last edit instead of dropping it. + const latestRef = useRef({ answers, scopes }); + latestRef.current = { answers, scopes }; + const timerRef = useRef | null>(null); + const dirtyRef = useRef(false); + const runAutoSave = (): void => { + timerRef.current = null; + dirtyRef.current = false; + void autoSaveForm(meta.id, toSubmitFrom(latestRef.current.answers), latestRef.current.scopes); + }; + /** Drop any pending auto-save — the explicit Continue/Done/Skip owns the write from here (avoids a draft + * save racing in AFTER the completing submit and reverting the section to in-progress). */ + const cancelAutoSave = (): void => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + dirtyRef.current = false; + }; + const firstRun = useRef(true); + useEffect(() => { + if (firstRun.current) { + firstRun.current = false; + return; + } + if (locked) return; // a locked (un-acked) section shows the gate, not the form — nothing to save + dirtyRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(runAutoSave, 600); + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [answers, scopes, locked]); + // Flush a pending draft on unmount so a quick Back / section switch never loses the last edit. `flushRef` + // always points at the latest closure (current `locked` + refs), so the unmount cleanup saves correctly. + const flushRef = useRef<() => void>(() => {}); + flushRef.current = (): void => { + if (!dirtyRef.current || locked) return; + if (timerRef.current) clearTimeout(timerRef.current); + runAutoSave(); + }; + useEffect(() => () => flushRef.current(), []); // The intimacy block is gated behind the shared 18+ acknowledgement (§3.3/§14.5). if (locked) { @@ -378,9 +408,12 @@ export function IntakeFormPanel({ @@ -388,7 +421,10 @@ export function IntakeFormPanel({ diff --git a/apps/desktop/src/renderer/src/stores/intakeStore.ts b/apps/desktop/src/renderer/src/stores/intakeStore.ts index 9a8f343..8c1a113 100644 --- a/apps/desktop/src/renderer/src/stores/intakeStore.ts +++ b/apps/desktop/src/renderer/src/stores/intakeStore.ts @@ -93,11 +93,14 @@ export const useIntakeStore = create((set, get) => ({ set({ busy: false, ...(next ? { state: next } : {}) }); }, autoSaveForm: async (sectionId, answers, sharing) => { - // No `busy` toggle — a background save, so the form's controls don't flicker as the user edits. + // No `busy` toggle — a background save, so the form's controls don't flicker as the user edits. And + // `complete: false` — a draft save, so editing/answering NEVER prematurely completes the section (only the + // explicit Continue/Done does), yet every answer + sharing change persists the moment it's made. const next = (await window.selfos?.intakeSubmitForm({ sectionId, answers, + complete: false, ...(sharing ? { sharing } : {}), })) ?? null; if (next) set({ state: next }); diff --git a/apps/desktop/src/shared/channels.ts b/apps/desktop/src/shared/channels.ts index 9e22086..7c8dd64 100644 --- a/apps/desktop/src/shared/channels.ts +++ b/apps/desktop/src/shared/channels.ts @@ -1132,6 +1132,11 @@ export interface SelfosBridge { * its category preset server-side. Empty array ⇒ Private (own context only). */ sharing?: Record; + /** + * Whether to mark the section complete (default true — the Continue/Done button). Auto-save passes `false` + * to persist a draft (answers + sharing) without completing a section being filled for the first time. + */ + complete?: boolean; }): Promise; /** The one-time 18+ acknowledgement for the intimacy block (shared with guided sessions). Requires `intake.own`. */ intakeAcknowledgeAdult(): Promise; diff --git a/apps/desktop/src/shared/coreBridge.ts b/apps/desktop/src/shared/coreBridge.ts index 65cbbc2..369a14f 100644 --- a/apps/desktop/src/shared/coreBridge.ts +++ b/apps/desktop/src/shared/coreBridge.ts @@ -767,6 +767,8 @@ const IntakeSubmitFormSchema = z.object({ answers: z.record(z.string(), IntakeAnswerValueSchema), // Per-question relationship-type sharing scopes (43 §6) — the trust boundary validates the types. sharing: z.record(z.string(), z.array(RelationshipTypeSchema)).optional(), + // Auto-save passes `false` to persist a draft without completing the section (default complete). + complete: z.boolean().optional(), }); const IntakeSetAnswerSharingSchema = z.object({ sectionId: z.string().min(1), @@ -4492,7 +4494,7 @@ export function createCoreBridge(host: BridgeHost): SelfosBridge { return buildIntakeState(ctx.fs, ctx.key, personId); }, intakeSubmitForm: async (input): Promise => { - const { sectionId, answers, sharing } = IntakeSubmitFormSchema.parse(input); + const { sectionId, answers, sharing, complete } = IntakeSubmitFormSchema.parse(input); const ctx = await host.vaultAndKey(); const personId = ctx ? await activePersonId() : null; if (!ctx || !personId || !(await activePersonCan(ctx.fs, ctx.key, 'intake.own'))) { @@ -4505,7 +4507,17 @@ export function createCoreBridge(host: BridgeHost): SelfosBridge { const prefs = await getGuidancePrefs(ctx.fs, ctx.key, personId); if (prefs.adultAcknowledged !== true) return buildIntakeState(ctx.fs, ctx.key, personId); } - await submitSectionForm(ctx.fs, ctx.key, personId, sectionId, answers, new Date(), sharing); + // `complete` defaults to true (the Continue/Done button); auto-save passes false to persist a draft. + await submitSectionForm( + ctx.fs, + ctx.key, + personId, + sectionId, + answers, + new Date(), + sharing, + complete ?? true, + ); return buildIntakeState(ctx.fs, ctx.key, personId); }, intakeAcknowledgeAdult: async (): Promise => { diff --git a/docs/specs/43-relationship-scoped-onboarding-sharing.md b/docs/specs/43-relationship-scoped-onboarding-sharing.md index a3b5635..8be0969 100644 --- a/docs/specs/43-relationship-scoped-onboarding-sharing.md +++ b/docs/specs/43-relationship-scoped-onboarding-sharing.md @@ -4,15 +4,20 @@ > **Depends on [`42`](42-relationship-scoped-sharing.md).** > > **Amendment (2026-06-26, owner decision — share auto-saves; no confirm).** Two UX changes after the user -> reported "I pick _share with partner all_ and Save, but it doesn't save." **(1) One tap, no confirm:** the -> §3.1/§8 sensitive-share confirm is **removed** — picking a scope (per-question or the bulk "share all") -> applies on a single tap. Safety is preserved by the **default**: a sensitive answer still STARTS Private -> (its category preset), so sharing it is still a deliberate, explicit choice; it just takes effect on one tap -> instead of a second confirm. **(2) Auto-save on edit:** on a section the person has already **completed** -> (i.e. is editing), an answer or sharing change **persists immediately** (debounced, silent — `autoSaveForm` -> re-runs `intake:submitForm`), so a sharing pick saves right away with no separate button; the primary button -> becomes **"Done"**. A first-time section is unchanged — it still uses the explicit **Continue** (which is what -> marks it complete; auto-save never completes a section being filled for the first time). +> reported "I pick _share with partner all_ and Save, but it doesn't save," and (corrected) that answers + the +> private status weren't saving on select either. **(1) One tap, no confirm:** the §3.1/§8 sensitive-share +> confirm is **removed** — picking a scope (per-question or the bulk "share all") applies on a single tap. +> Safety is preserved by the **default**: a sensitive answer still STARTS Private (its category preset), so +> sharing it is still a deliberate, explicit choice; it just takes effect on one tap instead of a second confirm. +> **(2) Auto-save on edit — EVERY section, as a DRAFT.** Every answer and sharing change **persists immediately** +> (debounced ~600ms, silent — `autoSaveForm` re-runs `intake:submitForm` with **`complete: false`**), whether +> the section is being **filled for the first time** OR edited later, with a **flush-on-unmount** so a quick Back +> / section-switch never drops the last edit. A draft save **never completes** the section (only nudges +> `notStarted`→`inProgress`); the explicit **Continue** (first-time) / **Done** (completed, relabeled from "Save +> changes") is the ONLY thing that marks a section complete + advances. `submitSectionForm` gained a +> `markComplete` param (default `true`) for this. **Correction:** the first build scoped auto-save to +> already-completed sections only, so first-time onboarding still didn't save on select — the real bug; auto-save +> now covers all sections. > Today, onboarding answers are **own-context-only** (all intake Insight facts hardcoded > `shareable: false`), and the only way to share anything is to finish the intake, run synthesis, then go to diff --git a/packages/core/src/intake/intakeService.test.ts b/packages/core/src/intake/intakeService.test.ts index 998ef2d..d5457fb 100644 --- a/packages/core/src/intake/intakeService.test.ts +++ b/packages/core/src/intake/intakeService.test.ts @@ -174,6 +174,30 @@ describe('intakeService', () => { expect(usage).toHaveLength(0); }); + it('submitSectionForm draft (markComplete:false) persists answers + sharing WITHOUT completing the section', async () => { + const fs = await setup(); + // The auto-save path (43 auto-save fix): persist a change as a draft so it saves the moment it's made. + const session = await submitSectionForm( + fs, + key, + 'p1', + 'basics', + { occupation: 'nurse' }, + NOW, + { occupation: ['partner'] }, + false, // draft — do NOT complete + ); + const basics = session.sections.find((s) => s.id === 'basics'); + expect(basics?.answers.occupation).toBe('nurse'); // the answer persisted… + expect(basics?.answerSharing?.occupation).toEqual(['partner']); // …and so did the sharing scope + expect(basics?.status).not.toBe('complete'); // a draft NEVER completes a section being filled out + expect(basics?.status).toBe('inProgress'); // notStarted → inProgress on a draft save + + // A subsequent explicit submit (default markComplete=true) completes it. + const done = await submitSectionForm(fs, key, 'p1', 'basics', { occupation: 'nurse' }, NOW); + expect(done.sections.find((s) => s.id === 'basics')?.status).toBe('complete'); + }); + it('fills appearance, importantDates (dateList, incomplete rows dropped), and interests (from passions)', async () => { const fs = await setup(); await submitSectionForm( diff --git a/packages/core/src/intake/intakeService.ts b/packages/core/src/intake/intakeService.ts index cb7550b..01fb86b 100644 --- a/packages/core/src/intake/intakeService.ts +++ b/packages/core/src/intake/intakeService.ts @@ -329,6 +329,12 @@ export async function submitSectionForm( * per the visible default. The resolved scope is stored explicitly on `section.answerSharing`. */ sharing?: Record, + /** + * Whether to mark the section **complete** (default `true` — the Continue/Done button). The auto-save passes + * `false` to persist a **draft** (answers + answerSharing) as the person edits, WITHOUT prematurely completing + * a first-time section (which is what triggers the portrait flow). A draft just moves `notStarted`→`inProgress`. + */ + markComplete = true, ): Promise { const def = getIntakeSection(sectionId); const session = await ensureIntakeSession(fs, key, personId, now); @@ -363,7 +369,13 @@ export async function submitSectionForm( nextSharing[qid] = dedupeTypes(chosen); } section.answerSharing = nextSharing; - if (section.status !== 'skipped') section.status = 'complete'; + if (markComplete) { + if (section.status !== 'skipped') section.status = 'complete'; + } else if (section.status === 'notStarted') { + // A draft auto-save: the person has started this section, but don't complete it (that's the explicit + // Continue) and never un-complete an already-complete/skipped one — only nudge notStarted → inProgress. + section.status = 'inProgress'; + } session.updatedAt = at; await writeEncryptedJson(fs, intakePath(personId), session, key); return session;