Skip to content

Commit 70e5dd7

Browse files
Programcodeclaude
andcommitted
Keep text typed during a save marked as an edit
A finished save cleared the edit mark of every field it sent, even one the student changed again while it ran, so that text was never sent and could be replaced by the stored copy. Each field now carries an edit count and each sheet opening a number; a save clears a mark only when neither changed. lib-edit-while-switching types on during a slow save and checks the next Save sends it, and first checks the editor opened mid-write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dc7c626 commit 70e5dd7

3 files changed

Lines changed: 37 additions & 11 deletions

File tree

‎.claude/skills/verify-debatelab/features/library-sources.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Users save reference texts to their Library. The Library belongs to the Current
77
- `lib-add` adds a source with a title and content. It shows at once while it saves, then a toast `Saved <title> to the Library`; its toggle is on, and the banner `Grounding Active` shows.
88
- `lib-toggle` pauses a source through its switch, which keeps it out of prompts.
99
- `lib-offline` offline, the switch does not move and a toast says `You are offline. Reconnect and try again.` A write that fails online puts the switch back with a toast.
10-
- `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet.
10+
- `lib-edit-while-switching` the source editor's untouched fields follow the stored source while it is open (a switch that rolls back shows as rolled back), and `Save` sends only the fields edited in the sheet. Text typed while a save runs stays an edit and goes out with the next `Save`.
1111
- `lib-dedupe` keeps one row per web page per debate. URLs match after normalizing (host without `www.`, no scheme, fragment, tracking parameters or trailing slash). Importing a page the debate has warns `This page is already in this debate's Library.` before any fetch; research refreshes the existing row instead of adding one.
1212
- `lib-layout` on a phone the row's controls take their own line, so a long title gets the row's width and two lines.
1313
- `lib-ground` includes only active sources' text in the `system` of later AI calls (Generate, Brainstorm, and others).

‎.claude/skills/verify-debatelab/recipes/library.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ test("lib-edit-while-switching: the editor follows a switch that rolls back, and
132132
await page.getByRole("switch", { name: "Toggle Race study" }).click();
133133
await page.getByRole("button", { name: "Race study", exact: true }).click();
134134
const sheet = page.getByRole("dialog");
135+
// The editor opened while the write was still running, so it shows the switch off.
136+
await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).not.toBeChecked();
135137
await expect(page.getByText("Could not change this source")).toBeVisible();
136138
// The switch went back on; the editor shows that, since it was not edited there.
137139
await expect(sheet.getByRole("switch", { name: "Use for AI grounding" })).toBeChecked();
@@ -142,6 +144,22 @@ test("lib-edit-while-switching: the editor follows a switch that rolls back, and
142144
const row = async () => (await admin.from("sources").select("content,is_active").eq("user_id", await user.userId()).eq("title", "Race study")).data;
143145
await expect.poll(row).toEqual([{ content: "Edited text.", is_active: true }]);
144146
await evidence.shot("01-editor-after-rollback");
147+
148+
// Typing on while a save runs keeps that text as an edit, so the next Save sends it.
149+
const slowSave = async (route: import("@playwright/test").Route) => {
150+
if (route.request().method() === "PATCH") await new Promise((resolve) => setTimeout(resolve, 1_500));
151+
await route.continue();
152+
};
153+
await page.route("**/rest/v1/sources?**", slowSave);
154+
const content = sheet.getByRole("textbox", { name: "Content" });
155+
await content.fill("Second edit.");
156+
await sheet.getByRole("button", { name: "Save" }).click();
157+
await content.fill("Second edit, typed on.");
158+
await expect.poll(row).toEqual([{ content: "Second edit.", is_active: true }]);
159+
await expect(sheet.getByRole("button", { name: "Save" })).toBeEnabled();
160+
await page.unroute("**/rest/v1/sources?**", slowSave);
161+
await sheet.getByRole("button", { name: "Save" }).click();
162+
await expect.poll(row).toEqual([{ content: "Second edit, typed on.", is_active: true }]);
145163
await evidence.json("02-row", await row());
146164
});
147165

‎src/pages/LibraryPage.tsx‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,11 @@ export default function LibraryPage() {
220220
const draftTitleRef = useRef<HTMLInputElement>(null);
221221
const draftContentRef = useRef<HTMLTextAreaElement>(null);
222222
const draftLoadedFor = useRef<string | null>(null);
223-
// Fields the student edited in the sheet. Save sends only these; the others
224-
// follow the stored source while the sheet is open.
225-
const touchedDraftFields = useRef(new Set<keyof typeof sourceDraft>());
223+
// Fields the student edited in the sheet, each with an edit count. Save sends
224+
// only these; the others follow the stored source while the sheet is open.
225+
const touchedDraftFields = useRef(new Map<keyof typeof sourceDraft, number>());
226+
// Counts sheet openings, so a save that lands after a reopen leaves the new edits alone.
227+
const draftOpening = useRef(0);
226228
const [isSavingDraft, setIsSavingDraft] = useState(false);
227229
const [newSource, setNewSource] = useState({
228230
title: '',
@@ -527,6 +529,7 @@ export default function LibraryPage() {
527529
};
528530
if (draftLoadedFor.current !== selectedSource.id) {
529531
draftLoadedFor.current = selectedSource.id;
532+
draftOpening.current += 1;
530533
touchedDraftFields.current.clear();
531534
setDraftErrors({});
532535
setSourceDraft(stored);
@@ -618,7 +621,8 @@ export default function LibraryPage() {
618621
}
619622
};
620623
const editDraft = (patch: Partial<typeof sourceDraft>) => {
621-
for (const key of Object.keys(patch) as Array<keyof typeof sourceDraft>) touchedDraftFields.current.add(key);
624+
const touched = touchedDraftFields.current;
625+
for (const key of Object.keys(patch) as Array<keyof typeof sourceDraft>) touched.set(key, (touched.get(key) ?? 0) + 1);
622626
setSourceDraft((draft) => ({ ...draft, ...patch }));
623627
};
624628
const handleSaveSourceDraft = async () => {
@@ -630,14 +634,18 @@ export default function LibraryPage() {
630634

631635
// Only what was edited here is written, so a change that reached this
632636
// source while the sheet was open (a switch, research) is not overwritten.
633-
const sourceId = selectedSource.id;
634-
const sent = Array.from(touchedDraftFields.current);
635-
const patch = Object.fromEntries(sent.map((key) => [key, sourceDraft[key]])) as Partial<typeof sourceDraft>;
637+
const opening = draftOpening.current;
638+
const sent = new Map(touchedDraftFields.current);
639+
const patch = Object.fromEntries(Array.from(sent.keys(), (key) => [key, sourceDraft[key]])) as Partial<typeof sourceDraft>;
636640
setIsSavingDraft(true);
637641
try {
638-
await updateSource(sourceId, patch);
639-
// The sheet may show another source by now; its edits are its own.
640-
if (draftLoadedFor.current === sourceId) for (const key of sent) touchedDraftFields.current.delete(key);
642+
await updateSource(selectedSource.id, patch);
643+
// A field counts as saved only if it was not edited again meanwhile and
644+
// the sheet still shows the opening this save came from.
645+
if (draftOpening.current === opening) {
646+
const touched = touchedDraftFields.current;
647+
for (const [key, edits] of sent) if (touched.get(key) === edits) touched.delete(key);
648+
}
641649
} catch (error) {
642650
toast({
643651
title: t('libraryPage.saveFailed', 'Could not save this source.'),

0 commit comments

Comments
 (0)