Skip to content

Commit dccd810

Browse files
dovvnloadingclaude
andauthored
fix(frontend): keep unsaved memory edits when a memory is added (#261)
Editing a memory row and then adding a new memory reverted the edit, with no warning and nothing to undo it. The draft list is re-seeded from the server whenever the server's answer changes. That effect exists for a reason -- #241 added it so a row the server normalized away (trimmed to nothing, a case-insensitive duplicate) stops sitting on screen looking saved -- but it re-seeded wholesale. Adding a memory also changes the server's answer, so every other row the user had edited and not yet saved was silently overwritten with the server's copy. Its own comment claimed the opposite ("leaves in-progress edits alone between saves"); that only held while nothing else changed the list. The effect now reconciles instead of replacing. Each row remembers the server value it was seeded from, and a server list that has changed carries every surviving row's in-progress value across by matching on that origin, building a fresh row only for genuinely new entries. Matching on the origin rather than the current value is the point: an edited row no longer equals its server value, which is exactly the case being preserved. Both behaviours are now pinned, including one test that exercises them together -- a normalized-away row is dropped in the same update that an edit to a surviving row is preserved -- so the fix cannot be satisfied by simply preferring the draft over the server or the reverse. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 369194d commit dccd810

2 files changed

Lines changed: 103 additions & 5 deletions

File tree

frontend/src/features/settings/MemoryPanel.test.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render, screen } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
3+
import { useState } from "react";
34
import { describe, expect, it, vi } from "vitest";
45
import { MemoryPanel } from "./MemoryPanel";
56

@@ -24,6 +25,39 @@ describe("MemoryPanel", () => {
2425
expect(input).toHaveFocus();
2526
});
2627

28+
it("keeps unsaved edits to other rows when a memory is added", async () => {
29+
// Adding a memory changes the server list, which re-seeded the whole
30+
// draft. Every other row the user had edited but not yet saved silently
31+
// reverted to the server's copy.
32+
const user = userEvent.setup();
33+
const onReplace = vi.fn<(memos: string[]) => Promise<void>>().mockResolvedValue();
34+
35+
function Host() {
36+
const [memos, setMemos] = useState(["First", "Second"]);
37+
return (
38+
<MemoryPanel
39+
memos={memos}
40+
busy={false}
41+
onAdd={async (memo: string) => { setMemos((current) => [...current, memo]); }}
42+
onReplace={onReplace}
43+
onClear={vi.fn<() => Promise<void>>().mockResolvedValue()}
44+
/>
45+
);
46+
}
47+
render(<Host />);
48+
49+
await user.clear(screen.getByRole("textbox", { name: "Memory 2" }));
50+
await user.type(screen.getByRole("textbox", { name: "Memory 2" }), "Edited");
51+
52+
await user.type(screen.getByRole("textbox", { name: "New memory" }), "Third");
53+
await user.click(screen.getByRole("button", { name: "Add memory" }));
54+
await screen.findByRole("textbox", { name: "Memory 3" });
55+
56+
expect(screen.getByRole("textbox", { name: "Memory 2" })).toHaveValue("Edited");
57+
await user.click(screen.getByRole("button", { name: "Save changes" }));
58+
expect(onReplace).toHaveBeenCalledWith(["First", "Edited", "Third"]);
59+
});
60+
2761
it("preserves edited rows when removing a neighboring row", async () => {
2862
const user = userEvent.setup();
2963
const onReplace = vi.fn<(memos: string[]) => Promise<void>>().mockResolvedValue();
@@ -161,4 +195,38 @@ describe("MemoryPanel server reconciliation", () => {
161195
});
162196
expect(screen.getByDisplayValue("kept")).toBeInTheDocument();
163197
});
198+
199+
it("drops a normalized-away row without discarding an edit to a surviving one", async () => {
200+
// Both halves at once: reconciliation must not be satisfied by simply
201+
// preferring the draft (which would keep the rejected row on screen) or
202+
// by simply preferring the server (the original defect).
203+
const user = userEvent.setup();
204+
const { waitFor } = await import("@testing-library/react");
205+
206+
function Harness() {
207+
const [memos, setMemos] = useState<string[]>(["kept", "duplicate"]);
208+
return (
209+
<>
210+
<button onClick={() => setMemos(["kept"])}>server responded</button>
211+
<MemoryPanel
212+
memos={memos}
213+
busy={false}
214+
onAdd={vi.fn<(memo: string) => Promise<void>>().mockResolvedValue()}
215+
onReplace={vi.fn<(memos: string[]) => Promise<void>>().mockResolvedValue()}
216+
onClear={vi.fn<() => Promise<void>>().mockResolvedValue()}
217+
/>
218+
</>
219+
);
220+
}
221+
render(<Harness />);
222+
223+
await user.clear(screen.getByRole("textbox", { name: "Memory 1" }));
224+
await user.type(screen.getByRole("textbox", { name: "Memory 1" }), "kept and edited");
225+
await user.click(screen.getByRole("button", { name: "server responded" }));
226+
227+
await waitFor(() => {
228+
expect(screen.queryByDisplayValue("duplicate")).not.toBeInTheDocument();
229+
});
230+
expect(screen.getByRole("textbox", { name: "Memory 1" })).toHaveValue("kept and edited");
231+
});
164232
});

frontend/src/features/settings/MemoryPanel.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,53 @@ type Props = {
99
onClear: () => Promise<void>;
1010
};
1111

12+
type DraftRow = {
13+
id: number;
14+
value: string;
15+
// The server value this row was last seeded from. An edited row no longer
16+
// equals it, which is exactly why reconciliation cannot match on `value`.
17+
origin: string;
18+
};
19+
1220
export function MemoryPanel({ memos, busy, onAdd, onReplace, onClear }: Props) {
1321
const [memo, setMemo] = useState("");
14-
const [draft, setDraft] = useState(() => memos.map((value, id) => ({ id, value })));
22+
const [draft, setDraft] = useState<DraftRow[]>(
23+
() => memos.map((value, id) => ({ id, value, origin: value })),
24+
);
1525

1626
// `memos` is the authoritative list the server returned. The draft was
1727
// seeded from it once and never re-derived, so an entry the server
1828
// normalized away -- trimmed to nothing, or a case-insensitive duplicate --
19-
// stayed on screen looking saved. Re-seed whenever the server's answer
20-
// actually changes, which leaves in-progress edits alone between saves.
29+
// stayed on screen looking saved.
30+
//
31+
// Re-seeding wholesale fixed that and broke something else: adding a memory
32+
// also changes the server's answer, so every *other* row the user had edited
33+
// and not yet saved silently reverted. Reconcile instead -- carry each
34+
// surviving row's in-progress value across by matching on the server value
35+
// it came from, and build a fresh row only for genuinely new entries.
2136
const lastServerMemos = useRef(memos);
2237
useEffect(() => {
2338
const previous = lastServerMemos.current;
2439
const changed =
2540
previous.length !== memos.length || previous.some((value, index) => value !== memos[index]);
2641
if (!changed) return;
2742
lastServerMemos.current = memos;
28-
setDraft(memos.map((value, id) => ({ id, value })));
43+
setDraft((current) => {
44+
const byOrigin = new Map<string, DraftRow[]>();
45+
for (const row of current) {
46+
const bucket = byOrigin.get(row.origin);
47+
if (bucket) bucket.push(row);
48+
else byOrigin.set(row.origin, [row]);
49+
}
50+
let nextId = current.reduce((highest, row) => Math.max(highest, row.id), -1) + 1;
51+
return memos.map((value) => {
52+
// shift(), so duplicate server values claim distinct rows.
53+
const existing = byOrigin.get(value)?.shift();
54+
return existing
55+
? { ...existing, origin: value }
56+
: { id: nextId++, value, origin: value };
57+
});
58+
});
2959
}, [memos]);
3060

3161
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
@@ -36,7 +66,7 @@ export function MemoryPanel({ memos, busy, onAdd, onReplace, onClear }: Props) {
3666
setDraft((current) => {
3767
if (current.some((item) => item.value.toLocaleLowerCase() === value.toLocaleLowerCase())) return current;
3868
const id = current.reduce((highest, item) => Math.max(highest, item.id), -1) + 1;
39-
return [...current, { id, value }];
69+
return [...current, { id, value, origin: value }];
4070
});
4171
setMemo("");
4272
}).catch(() => undefined);

0 commit comments

Comments
 (0)