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
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions apps/desktop/e2e/launch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>) — 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<string, IntakeAnswerValue> => {
const toSubmitFrom = (src: AnswerMap): Record<string, IntakeAnswerValue> => {
const out: Record<string, IntakeAnswerValue> = {};
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;
Expand All @@ -226,6 +210,52 @@ export function IntakeFormPanel({
}
return out;
};
const toSubmit = (): Record<string, IntakeAnswerValue> => 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<ReturnType<typeof setTimeout> | 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) {
Expand Down Expand Up @@ -378,17 +408,23 @@ export function IntakeFormPanel({
<Button
variant="primary"
disabled={busy}
onClick={() => void submitForm(meta.id, toSubmit(), scopes).then(onAdvance)}
onClick={() => {
cancelAutoSave(); // this explicit (completing) submit owns the write — drop any pending draft
void submitForm(meta.id, toSubmit(), scopes).then(onAdvance);
}}
>
{/* A complete section auto-saves as you edit, so this just flushes + moves on. */}
{/* Edits auto-save as a draft as you go; this flushes the latest, completes the section, + moves on. */}
{complete ? 'Done' : 'Continue'}
<ArrowRight size={16} aria-hidden="true" />
</Button>
{!complete ? (
<Button
variant="ghost"
disabled={busy}
onClick={() => void skipSection(meta.id).then(onAdvance)}
onClick={() => {
cancelAutoSave(); // skipping owns the write — don't let a draft save resurrect the answers
void skipSection(meta.id).then(onAdvance);
}}
>
Skip this section
</Button>
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/renderer/src/stores/intakeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,14 @@ export const useIntakeStore = create<IntakeStoreState>((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 });
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,11 @@ export interface SelfosBridge {
* its category preset server-side. Empty array ⇒ Private (own context only).
*/
sharing?: Record<string, RelationshipType[]>;
/**
* 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<IntakeState>;
/** The one-time 18+ acknowledgement for the intimacy block (shared with guided sessions). Requires `intake.own`. */
intakeAcknowledgeAdult(): Promise<IntakeState>;
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/shared/coreBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -4492,7 +4494,7 @@ export function createCoreBridge(host: BridgeHost): SelfosBridge {
return buildIntakeState(ctx.fs, ctx.key, personId);
},
intakeSubmitForm: async (input): Promise<IntakeState> => {
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'))) {
Expand All @@ -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<IntakeState> => {
Expand Down
Loading