Skip to content
25 changes: 18 additions & 7 deletions app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
const { GuideStore } = require('../core/store');
const { libraryLocationFor } = require('./data-dir');
const { Settings } = require('../core/settings');
const { saveGuideFromEditor, saveStepFromEditor } = require('../core/editor-save');
const { GoogleDrive } = require('./google-drive');
const { OneDrive } = require('./onedrive');
const { Dropbox } = require('./dropbox');
Expand Down Expand Up @@ -687,7 +688,13 @@ function watchAgentChanges() {
}
seenAt = changed.latest;
for (const guideId of changed.guideIds) {
if (!store.guideExists(guideId)) continue;
if (!store.guideExists(guideId)) {
// The agent moved it to the Trash. Like any local Trash, that stays
// on this computer: cloud sync neither deletes nor restores it.
searchIndex.removeGuide(guideId);
sendToRenderer('guide:changed-by-agent', { guideId, deleted: true });
continue;
}
reindex(guideId);
sendToRenderer('guide:changed-by-agent', { guideId });
}
Expand Down Expand Up @@ -860,11 +867,14 @@ function setupIpc() {
h('guide:quality', ({ guideId }) => qualityFindings(buildRenderAst(store, guideId, {
globals: settings.getGlobalPlaceholders(), reviewDays: exportReviewDays(),
}), stepNumberMap(orderedSteps(guideId))), { validate: (a) => c.id(a.guideId) });
h('guide:save', ({ guide }) => {
const saved = store.saveGuide(guide);
// An AI agent may have moved the guide to the Trash meanwhile; a late save
// from the editor must not recreate it, which would also block its restore.
h('guide:save', ({ guide, baseGuide }) => {
const saved = saveGuideFromEditor(store, guide, baseGuide);
reindex(guide.guideId);
return saved;
}, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
}, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId)
&& (a.baseGuide == null || (security.isPlainArgs(a.baseGuide) && a.baseGuide.guideId === a.guide.guideId)) });
h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
Expand All @@ -874,11 +884,12 @@ function setupIpc() {
maxChars: IMAGE_BUDGET,
validate: (a) => c.id(a.guideId) && c.optionalBase64(a.imageBase64) && c.optionalNumber(a.position, 0, 100000),
});
h('step:save', ({ guideId, step }) => {
const saved = store.saveStep(guideId, step);
h('step:save', ({ guideId, step, baseStep }) => {
const saved = saveStepFromEditor(store, guideId, step, baseStep);
reindex(guideId);
return saved;
}, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
}, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId)
&& (a.baseStep == null || (security.isPlainArgs(a.baseStep) && a.baseStep.stepId === a.step.stepId)) });
h('step:delete', ({ guideId, stepId }) => {
store.deleteStep(guideId, stepId);
reindex(guideId);
Expand Down
22 changes: 19 additions & 3 deletions app/renderer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,25 @@ class StepForgeApp {
await ensurePendingBrandRasters(api);
}

/** An AI agent rewrote a guide's text: show it without losing the user's unsaved edits. */
/** An AI agent changed a guide: show it without losing the user's unsaved edits. */
async onGuideChangedByAgent(payload) {
if (!payload || !payload.guideId) return;
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
const open = this.state.view === 'editor' && this.editor.guideId === payload.guideId;
if (payload.deleted) {
if (open) {
// Saving now would write into a guide that is in the Trash.
this.editor.saveStepDebounced.cancel();
this.editor.saveGuideDebounced.cancel();
this.editor.pendingSave = false;
this.editor.pendingGuideSave = false;
await this.showLibrary();
toast('An AI agent moved this guide to the Trash. You can restore it from there.', { ms: 6000 });
} else if (this.state.view === 'library') {
await this.refreshLibrary();
}
return;
}
if (open) {
await this.editor.reloadAfterOutsideChange();
toast('An AI agent updated this guide.');
} else if (this.state.view === 'library') {
Expand Down Expand Up @@ -1163,8 +1178,9 @@ class StepForgeApp {
const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title });
if (title == null || !title.trim()) return;
const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide;
const baseGuide = JSON.parse(JSON.stringify(fullGuide));
fullGuide.title = title.trim();
await api.guide.save({ guide: fullGuide });
await api.guide.save({ guide: fullGuide, baseGuide });
if (this.state.view === 'editor' && this.editor.guideId === fullGuide.guideId) {
await this.editor.reload(this.editor.selectedStepId);
}
Expand Down
2 changes: 1 addition & 1 deletion app/renderer/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ function showSettingsDialog({
ollamaModel),
el('div.settings-test', {}, aiStatus, testAiBtn, ollamaModels)),
settingsCard('AI agents (Claude, Codex)',
el('div.setting-desc', {}, 'AI apps such as Claude and Codex can connect to StepForge and write your guides for you. They can rewrite text, make substeps, add placeholders, and draw on screenshots, but they can’t reorder steps or delete anything.'),
el('div.setting-desc', {}, 'AI apps such as Claude and Codex can connect to StepForge and edit your guides. They can create and delete guides and steps, reorder steps, replace or remove screenshots, edit text, add placeholders, and add or delete annotations except blurs. StepForge saves snapshots before step creation, moves, deletions and screenshot changes; restore them from More → Backups & snapshots. Deleted guides go to the Trash.'),
settingRow('Let AI agents edit your guides', 'Turn off to cut off every connected agent at once.', makeSwitch(agentsEnabled, 'Let AI agents edit your guides')),
settingRow('Agents can see screenshots', 'Blurred areas are filled in first. Turn off to share step text only.', makeSwitch(agentsScreenshots, 'Agents can see screenshots')),
settingRow('Let AI agents edit brand profiles', 'Agents can make and change brand profiles: logo, colors, fonts and CSS, and choose the one exports use. They can’t delete a profile.', makeSwitch(agentsBranding, 'Let AI agents edit brand profiles')),
Expand Down
41 changes: 35 additions & 6 deletions app/renderer/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,18 @@ class GuideEditor {
* Pending edits are saved first, so the user's own unsaved work always wins.
*/
async reloadAfterOutsideChange() {
if (this.pendingSave) await this.flushStep();
if (this.pendingSave) {
const { guide } = await api.guide.get({ guideId: this.guideId });
if (!guide.stepsOrder.includes(this.selectedStepId)) {
this.saveStepDebounced.cancel();
this.pendingSave = false;
this.saveError = null;
this.onToast('An AI agent deleted the step you were editing. Its saved version is in Backups & snapshots.');
} else {
await this.flushStep();
if (this.pendingSave) return; // Keep unsaved edits if the write failed.
}
}
if (this.pendingGuideSave) await this.flushGuide();
await this.reload(this.selectedStepId);
}
Expand All @@ -537,8 +548,10 @@ class GuideEditor {
const { guide, steps } = await api.guide.get({ guideId: this.guideId });
if (token !== this.stepLoadToken) return;
this.guide = guide;
this.savedGuideBaseline = clone(guide);
this.steps = steps;
this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
this.savedStepBaselines = new Map(steps.map((step) => [step.stepId, clone(step)]));
this.selectedSteps = new Set([...this.selectedSteps].filter((id) => this.stepMap.has(id)));
if (!this.stepMap.has(this.stepSelectionAnchor)) this.stepSelectionAnchor = null;
if (!this.shellMounted) this.mountShell();
Expand Down Expand Up @@ -1739,6 +1752,9 @@ class GuideEditor {
async restoreHistoryRecord(record) {
if (!record || !record.step) return;
const step = clone(record.step);
// Undo is an intentional new edit of the current copy, not an autosave
// based on the old revision captured in the history record.
step.revision = this.stepMap.get(step.stepId)?.revision ?? step.revision;
this.selectedStepId = step.stepId;
this.beforeCanvasSnapshot = null;
this.saveStepDebounced.cancel();
Expand Down Expand Up @@ -1867,7 +1883,7 @@ class GuideEditor {
// surface it, and retry on the next edit or explicit save.
let saved;
try {
saved = await api.step.save({ guideId: this.guideId, step });
saved = await api.step.save({ guideId: this.guideId, step, baseStep: this.savedStepBaselines?.get(step.stepId) });
} catch (err) {
this.pendingSave = true;
this.saveError = (err && err.message) || 'Save failed';
Expand All @@ -1877,7 +1893,9 @@ class GuideEditor {
}
this.pendingSave = false;
this.saveError = null;
const committed = this.commitSavedStep(saved);
const mergedOutsideChange = saved.revision > (step.revision || 0) + 1;
const committed = this.commitSavedStep(saved, { replaceBlocks: mergedOutsideChange });
if (mergedOutsideChange) this.renderAll();
if (this.selectedStepId === committed.stepId) {
this.renderStepList();
this.syncStepFields();
Expand All @@ -1894,6 +1912,8 @@ class GuideEditor {

commitSavedStep(saved, { replaceBlocks = false } = {}) {
if (!saved || !saved.stepId) return saved;
this.savedStepBaselines ||= new Map();
this.savedStepBaselines.set(saved.stepId, clone(saved));
const existing = this.stepMap.get(saved.stepId);
if (!existing || existing === saved) {
this.stepMap.set(saved.stepId, saved);
Expand All @@ -1920,11 +1940,20 @@ class GuideEditor {
return existing;
}

async saveGuideEdits() {
const saved = await api.guide.save({ guide: this.guide, baseGuide: this.savedGuideBaseline });
if (saved?.guideId) {
this.guide = saved;
this.savedGuideBaseline = clone(saved);
}
return saved;
}

async flushGuide() {
while (this.linkedSaveInFlight) await this.linkedSaveInFlight; // see flushStep
if (!this.guide) return;
try {
await api.guide.save({ guide: this.guide });
await this.saveGuideEdits();
} catch (err) {
this.pendingGuideSave = true;
this.saveError = (err && err.message) || 'Save failed';
Expand Down Expand Up @@ -2541,7 +2570,7 @@ class GuideEditor {
values: this.guide.placeholders || {},
onSave: async (values) => {
this.guide.placeholders = values;
await api.guide.save({ guide: this.guide });
await this.saveGuideEdits();
this.onToast('Placeholders saved.');
},
});
Expand All @@ -2560,7 +2589,7 @@ class GuideEditor {
this.guide.metadata = metadata;
this.guide.descriptionHtml = textToHtml(description);
this.guide.cloud = { ...(this.guide.cloud || {}), sharingEnabled };
await api.guide.save({ guide: this.guide });
await this.saveGuideEdits();
this.onToast('Guide information saved.');
},
onCloudSnapshots: async () => {
Expand Down
Loading
Loading