Add a per-project Notes tab with a send-to-agent action #48
@@ -67,6 +67,9 @@ describe("useNotes", () => {
|
|||||||
const { result } = renderHook(() => useNotes("p1"));
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Mock the re-read to return the edited note
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.saveNote(note({ body: "edited" }));
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
});
|
});
|
||||||
@@ -93,4 +96,71 @@ describe("useNotes", () => {
|
|||||||
renderHook(() => useNotes(""));
|
renderHook(() => useNotes(""));
|
||||||
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears the first project's notes when the projectId changes to another non-empty value", 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).toHaveLength(1);
|
||||||
|
|
||||||
|
// Change to a different project before the new fetch resolves
|
||||||
|
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
|
||||||
|
// The old notes should be cleared immediately
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no stale notes on screen when a load fails", async () => {
|
||||||
|
listNotes.mockResolvedValueOnce([note()]);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Switch to a project whose load fails
|
||||||
|
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
expect(pushToast).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with the list the backend returned when saving a new note", async () => {
|
||||||
|
// Initially one note
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
||||||
|
const newNote = note({ id: "n2", title: "New" });
|
||||||
|
listNotes.mockResolvedValueOnce([newNote, note()]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(newNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(2);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the list after a successful save rather than patching in place", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const callCountBefore = listNotes.mock.calls.length;
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// listNotes should be called again after the save
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function useNotes(projectId: string) {
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setNotes([]);
|
||||||
commands
|
commands
|
||||||
.listNotes(projectId)
|
.listNotes(projectId)
|
||||||
.then((loaded) => {
|
.then((loaded) => {
|
||||||
@@ -50,6 +51,7 @@ export function useNotes(projectId: string) {
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
setNotes([]);
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "Could not load notes for this project",
|
message: "Could not load notes for this project",
|
||||||
@@ -85,14 +87,18 @@ export function useNotes(projectId: string) {
|
|||||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
setSaveState({ status: "saving", error: null });
|
setSaveState({ status: "saving", error: null });
|
||||||
try {
|
try {
|
||||||
const saved = await commands.saveNote(projectId, note);
|
await commands.saveNote(projectId, note);
|
||||||
setNotes((current) => {
|
// Re-read the canonical list from the backend. A successful save stamps a new
|
||||||
const index = current.findIndex((n) => n.id === saved.id);
|
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
|
||||||
if (index === -1) return [saved, ...current];
|
// so the record's position has changed and positional patching would disagree with
|
||||||
const next = [...current];
|
// what a reload would show.
|
||||||
next[index] = saved;
|
try {
|
||||||
return next;
|
const reloaded = await commands.listNotes(projectId);
|
||||||
});
|
setNotes(reloaded);
|
||||||
|
} catch {
|
||||||
|
// Keep the save reported as successful (it was) and leave the existing list alone
|
||||||
|
// rather than clearing it if the re-read fails.
|
||||||
|
}
|
||||||
succeeded();
|
succeeded();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -108,7 +114,10 @@ export function useNotes(projectId: string) {
|
|||||||
const createNote = useCallback(async () => {
|
const createNote = useCallback(async () => {
|
||||||
const note = draft();
|
const note = draft();
|
||||||
// Held locally first so the editor can focus it immediately; the save
|
// Held locally first so the editor can focus it immediately; the save
|
||||||
// happens on blur like every other edit.
|
// happens on blur like every other edit. The note does not exist backend-side,
|
||||||
|
// so no re-read can place it and no backend ordering applies to it yet. Prepending
|
||||||
|
// puts it at the top where the user can see it immediately, and on the first save
|
||||||
|
// its canonical position is established.
|
||||||
setNotes((current) => [note, ...current]);
|
setNotes((current) => [note, ...current]);
|
||||||
return note;
|
return note;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user