Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7d82707
feat(notes): add pure section-scoped transforms for the note body
Optic00 Jul 28, 2026
db8966a
fix(notes): make duplicate-heading edits match parseMeetingMarkdown l…
Optic00 Jul 28, 2026
b9296fa
feat(notes): add an atomic file write helper
Optic00 Jul 28, 2026
648aa8c
fix(notes): make the atomic-write failure test exercise the real clea…
Optic00 Jul 28, 2026
b6221cc
feat(notes): add the original-output snapshot sidecar
Optic00 Jul 28, 2026
286f9f0
fix(notes): close snapshot-sidecar overwrite and no-op gaps from review
Optic00 Jul 28, 2026
73da9b9
fix(notes): restore readSnapshot's never-throws contract
Optic00 Jul 28, 2026
7869858
feat(notes): patch summary sections on markdown notes atomically
Optic00 Jul 28, 2026
28924b5
fix(notes): gate note edits on the parsers' normalized grammar
Optic00 Jul 28, 2026
dff5ce3
feat(notes): snapshot the model output when a note is generated
Optic00 Jul 28, 2026
5d9133a
fix(notes): anchor and harden the original-snapshot writer
Optic00 Jul 28, 2026
cecd310
feat(notes): edit a generated note behind an explicit edit affordance
Optic00 Jul 28, 2026
426457a
fix(notes): align the note editor fields and stop losing drafts on th…
Optic00 Jul 28, 2026
503e879
feat(notes): confirm before a regenerate replaces edited sections
Optic00 Jul 28, 2026
8189ab1
feat(chat): include participants and action items in the meeting context
Optic00 Jul 28, 2026
13a5549
fix(chat): stop a participant or action item from forging a context s…
Optic00 Jul 28, 2026
4eb6fac
test(e2e): cover the note editor's interaction contract and close two…
Optic00 Jul 28, 2026
c1c0520
fix(notes): close the two whole-branch gaps a deleted note and an ope…
Optic00 Jul 28, 2026
4d9324c
fix(chat): stop a dict-shaped legacy note from crashing cross-note chat
Optic00 Jul 28, 2026
d14b89a
fix(notes): stop a My-notes autosave reporting success over an unwrit…
Optic00 Jul 28, 2026
a83d09b
fix(notes): stop the regenerate confirm from contradicting itself
Optic00 Jul 29, 2026
adbae6e
fix(notes): carry the regenerate confirm's fixed wording to re-transc…
Optic00 Jul 29, 2026
399e813
Merge branch 'main' into feat/editable-note
Optic00 Aug 2, 2026
3758dc9
Merge branch 'main' into feat/editable-note
Optic00 Aug 2, 2026
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
26 changes: 26 additions & 0 deletions app/atomic-write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

// Write via a temp file in the SAME directory, then rename. rename(2) is atomic
// within a filesystem, so a reader either sees the old file or the new one and
// never a truncated one. The temp name is randomised so two concurrent writers
// cannot collide, and it is dot-prefixed so a directory listing stays clean.
function writeFileAtomicSync(targetPath, data) {
const dir = path.dirname(targetPath);
const base = path.basename(targetPath);
const tmpPath = path.join(dir, `.${base}.${crypto.randomBytes(6).toString('hex')}.tmp`);
try {
fs.writeFileSync(tmpPath, data, 'utf8');
fs.renameSync(tmpPath, targetPath);
} catch (err) {
try {
fs.unlinkSync(tmpPath);
} catch (_) {
// The temp file may never have been created. Nothing to clean up.
}
throw err;
}
}

module.exports = { writeFileAtomicSync };
50 changes: 50 additions & 0 deletions app/atomic-write.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');

const { writeFileAtomicSync } = require('./atomic-write');

function tmpFile(contents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atomic-write-'));
const file = path.join(dir, 'note.md');
fs.writeFileSync(file, contents, 'utf8');
return file;
}

test('writeFileAtomicSync replaces the file contents', () => {
const file = tmpFile('old');
writeFileAtomicSync(file, 'new');
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'new');
});

test('writeFileAtomicSync creates a file that does not exist yet', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atomic-write-'));
const file = path.join(dir, 'fresh.md');
writeFileAtomicSync(file, 'hello');
assert.strictEqual(fs.readFileSync(file, 'utf8'), 'hello');
});

test('a failed write leaves the original intact and removes the temp file', (t) => {
const file = tmpFile('original');
const dir = path.dirname(file);
const before = fs.readdirSync(dir);

// Force the failure AFTER the temp file has genuinely been written to disk,
// by making the rename step itself throw. This is the real window the
// atomic-write guarantee protects: the temp file exists, the rename fails,
// and the catch block's cleanup must remove it without touching the
// original. Using node:test's built-in mock keeps this dependency-free and
// restores fs.renameSync automatically once the test ends.
t.mock.method(fs, 'renameSync', () => {
throw new Error('boom');
});

assert.throws(() => {
writeFileAtomicSync(file, 'new');
});

assert.strictEqual(fs.readFileSync(file, 'utf8'), 'original');
assert.deepStrictEqual(fs.readdirSync(dir).sort(), before.sort());
});
48 changes: 46 additions & 2 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ function install({ ipcMain }) {
// note file; the T1 seeds are consts, so we overlay here instead.
const meetingOverlay = {};

// Every update-meeting call, verbatim, so a T1 spec can assert the bridge
// was invoked exactly once with exactly the fields it expects (note-edit.t1).
// Read via ElectronApplication.evaluate (the main process), not through
// window.stenoai in the page: contextBridge's exposed API is read-only from
// the renderer's world, so monkey-patching window.stenoai.meetings.update in
// page.evaluate silently no-ops instead of recording anything.
global.__stenoaiE2eUpdateMeetingCalls = [];

// Every reprocess-meeting call, same recording trick and the same reason. A
// spec that has to prove a rebuild did NOT start cannot assert on the UI
// (with the editor open the streaming view is suppressed on purpose), so the
// only honest evidence is that the IPC was never reached.
global.__stenoaiE2eReprocessCalls = [];

// In-flight soft-deletes (#234), id → the deleted meeting. Mirrors main's
// pendingDelete map just far enough for undo to hand the row back.
const pendingDeletes = {};
Expand Down Expand Up @@ -462,15 +476,45 @@ function install({ ipcMain }) {
// permissive default hand the toast an undefined array to map over.
'list-pending-deletes': async () => ({ success: true, pending: [] }),

// My notes autosave: persist the overlay so a follow-up get-meeting sees
// the edit (mirrors the real update-meeting body-section upsert).
// My notes autosave AND the note editor (D9) share this one handler, like
// the real update-meeting IPC. `user_notes` overlays the My notes tab; the
// four structural note fields overlay the Standard note AND accumulate
// into `edited_fields`, mirroring app/note-snapshot.js's markEdited so the
// regenerate guard (which reads meeting.edited_fields) sees the same shape
// under mock IPC that it would from the real sidecar.
// Recorded, then answered exactly the way the permissive unknown-channel
// default did (`{ success: true }`, no events): the renderer stays in its
// "analyzing" state, which is what the floating-bar T1 already relies on.
'reprocess-meeting': async (_event, summaryFile, regenerateTitle, sessionName) => {
global.__stenoaiE2eReprocessCalls.push({ summaryFile, regenerateTitle, sessionName });
return { success: true };
},

'update-meeting': async (_event, summaryFile, patch) => {
global.__stenoaiE2eUpdateMeetingCalls.push({ summaryFile, patch });
if (patch && typeof patch.user_notes === 'string') {
meetingOverlay[summaryFile] = {
...(meetingOverlay[summaryFile] || {}),
user_notes: patch.user_notes,
};
}
const changed = [];
for (const key of ['summary', 'key_points', 'action_items', 'discussion_areas']) {
if (patch && patch[key] !== undefined) {
meetingOverlay[summaryFile] = {
...(meetingOverlay[summaryFile] || {}),
[key]: patch[key],
};
changed.push(key);
}
}
if (changed.length) {
const existing = meetingOverlay[summaryFile]?.edited_fields || [];
meetingOverlay[summaryFile] = {
...(meetingOverlay[summaryFile] || {}),
edited_fields: [...new Set([...existing, ...changed])],
};
}
return { success: true, message: 'ok' };
},

Expand Down
Loading
Loading