Add a per-project Notes tab with a send-to-agent action #48
@@ -163,4 +163,47 @@ describe("useNotes", () => {
|
|||||||
// listNotes should be called again after the save
|
// listNotes should be called again after the save
|
||||||
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not overwrite the new project's notes when a stale save resolves", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes[0].id).toBe("n1");
|
||||||
|
|
||||||
|
// Start a save for p1 that hangs
|
||||||
|
let resolveSave: ((note: Note) => void) | undefined;
|
||||||
|
saveNote.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let savePromise: Promise<boolean> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
savePromise = result.current.saveNote(note({ id: "n1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to p2 while the save is in flight
|
||||||
|
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Now p2's note should be displayed
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
|
||||||
|
// Resolve the stale p1 save
|
||||||
|
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave?.(note({ id: "n1", body: "edited" }));
|
||||||
|
await savePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
// p2's note should still be displayed, not p1's
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function useNotes(projectId: string) {
|
|||||||
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const currentProjectId = useRef(projectId);
|
||||||
|
currentProjectId.current = projectId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -94,7 +96,12 @@ export function useNotes(projectId: string) {
|
|||||||
// what a reload would show.
|
// what a reload would show.
|
||||||
try {
|
try {
|
||||||
const reloaded = await commands.listNotes(projectId);
|
const reloaded = await commands.listNotes(projectId);
|
||||||
|
// Guard against a stale callback: if the project changed while the save was in
|
||||||
|
// flight, don't overwrite the new project's list with the old one. The save itself
|
||||||
|
// still succeeded, so succeeded() still fires; only the list replacement is skipped.
|
||||||
|
if (currentProjectId.current === projectId) {
|
||||||
setNotes(reloaded);
|
setNotes(reloaded);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the save reported as successful (it was) and leave the existing list alone
|
// Keep the save reported as successful (it was) and leave the existing list alone
|
||||||
// rather than clearing it if the re-read fails.
|
// rather than clearing it if the re-read fails.
|
||||||
@@ -126,7 +133,12 @@ export function useNotes(projectId: string) {
|
|||||||
async (noteId: string) => {
|
async (noteId: string) => {
|
||||||
try {
|
try {
|
||||||
await commands.deleteNote(projectId, noteId);
|
await commands.deleteNote(projectId, noteId);
|
||||||
|
// Guard against a stale callback: if the project changed while the delete was in
|
||||||
|
// flight, don't modify the list. Note ids are UUIDs so a stale filter cannot match
|
||||||
|
// another project's note, but applying the same guard for consistency.
|
||||||
|
if (currentProjectId.current === projectId) {
|
||||||
setNotes((current) => current.filter((n) => n.id !== noteId));
|
setNotes((current) => current.filter((n) => n.id !== noteId));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
||||||
|
|||||||
Reference in New Issue
Block a user